diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 0dea966832..5300415592 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -84,6 +84,7 @@ apps/backend/ │ ├── events/ # Event bus for internal pub/sub │ ├── gateway/ # WebSocket gateway │ ├── github/ # GitHub API integration (PRs, reviews, webhooks) +│ ├── githubauth/ # Shared GitHub credential-broker environment contract │ ├── common/ # Shared utilities, config, logger │ ├── integration/ # External integrations │ ├── integrations/ # Shared shapes for third-party integrations @@ -124,6 +125,16 @@ apps/backend/ **Watcher Dispatch Coordinator** (`internal/orchestrator/watcher_dispatch.go`) is the single pipeline that turns a freshly-observed external issue (Linear, Jira, future) into a Kandev task. Bus subscribers for each integration forward the event to `WatcherDispatchCoordinator.Dispatch` with a per-integration `WatcherSource` implementation (`source_linear.go`, `source_jira.go`). Source methods carry the integration-specific bits (reserve dedup, build task request, attach task ID, release, auto-start params); the coordinator owns the cross-cutting pipeline (create task, decide auto-start, error/release handling). Add a new watcher = implement `WatcherSource` + register a one-line bus subscriber. Do NOT add another `createXIssueTask` mirror. +**GitHub App registration catalog** (`internal/github/`) stores zero or more managed/imported App +registrations; none is a global default. Workspace App connections must carry both registration ID +and installation ID. Runtime clients, token caches, broker leases, OAuth state, webhook delivery, +and service cache scopes must retain registration identity and credential generation. Reusing a +registration intentionally shares root App credentials and bot identity; installation grants and +workspace credentials remain isolated. Registration create/import/select/install belongs to the +workspace GitHub settings flow, and backend startup configuration is not an App credential source. +Registration-specific callback and webhook routes select one candidate registration but never +replace state verification, installation association, or HMAC verification. + **Workflow Engine** (`internal/workflow/engine/`) provides typed state-machine evaluation: - `Engine.HandleTrigger()` evaluates step actions for triggers (on_enter, on_turn_start, on_turn_complete, on_exit) - `TransitionStore` interface abstracts persistence (implemented by `orchestrator.workflowStore`) diff --git a/apps/backend/cmd/agentctl/github_cli_shim.go b/apps/backend/cmd/agentctl/github_cli_shim.go new file mode 100644 index 0000000000..a4dc035ac8 --- /dev/null +++ b/apps/backend/cmd/agentctl/github_cli_shim.go @@ -0,0 +1,328 @@ +package main + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/kandev/kandev/internal/githubauth" +) + +const envGitHubCLIShimDir = "KANDEV_GITHUB_CLI_SHIM_DIR" + +const windowsOS = "windows" + +type githubCLILookPath func(file, path string) (string, error) + +type githubCLICommandRunner func( + ctx context.Context, + executable string, + args []string, + env []string, + stdin io.Reader, + stdout, stderr io.Writer, +) error + +func runGitHubCLIShim( + ctx context.Context, + args []string, + stdin io.Reader, + stdout, stderr io.Writer, + getenv func(string) string, + environ func() []string, + httpClient *http.Client, + shimDir string, + lookPath githubCLILookPath, + runner githubCLICommandRunner, +) error { + client, err := newGitHubCLIShimCredentialBrokerClient(ctx, args, getenv, httpClient) + if err != nil { + return err + } + credential, err := client.resolve(ctx) + if err != nil { + return err + } + realPath := pathWithoutDirectory(getenv("PATH"), shimDir) + executable, err := lookPath("gh", realPath) + if err != nil { + return fmt.Errorf("find real gh CLI: %w", err) + } + configDir, err := isolatedGitHubCLIConfigDir(client.request.TaskID, client.request.SessionID) + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(configDir) }() + childEnv := replaceEnvironment(environ(), map[string]string{ + "GH_TOKEN": credential.Password, + "GH_CONFIG_DIR": configDir, + "PATH": realPath, + }, "GITHUB_TOKEN") + return runner(ctx, executable, args, childEnv, stdin, stdout, stderr) +} + +type githubCLIRepository struct { + host string + owner string + repo string +} + +func newGitHubCLIShimCredentialBrokerClient( + ctx context.Context, + args []string, + getenv func(string) string, + httpClient *http.Client, +) (*githubCredentialBrokerClient, error) { + if strings.TrimSpace(getenv(githubauth.CredentialScopesEnv)) == "" { + return newGitHubCredentialBrokerClient(getenv, httpClient) + } + target, err := resolveGitHubCLIRepository(ctx, args, getenv) + if err != nil { + return nil, err + } + if target == nil { + return newGitHubCredentialBrokerClient(getenv, httpClient) + } + client, err := newGitHubCredentialBrokerClientForInput(getenv, httpClient, map[string]string{ + "protocol": "https", + "host": target.host, + "path": target.owner + "/" + target.repo, + }) + if err != nil { + return nil, fmt.Errorf("select GitHub credential for repository: %w", err) + } + return client, nil +} + +func resolveGitHubCLIRepository( + ctx context.Context, + args []string, + getenv func(string) string, +) (*githubCLIRepository, error) { + defaultHost := strings.TrimSpace(getenv(githubauth.CredentialHostEnv)) + raw, found, err := githubCLIRepositoryArgument(args) + if err != nil { + return nil, err + } + if found { + return parseGitHubCLIRepository(raw, defaultHost) + } + if raw = strings.TrimSpace(getenv("GH_REPO")); raw != "" { + return parseGitHubCLIRepository(raw, defaultHost) + } + cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin") + output, err := cmd.Output() + if err != nil { + return nil, nil + } + target, err := parseGitHubCLIRepository(strings.TrimSpace(string(output)), defaultHost) + if err != nil { + return nil, nil + } + return target, nil +} + +func githubCLIRepositoryArgument(args []string) (string, bool, error) { + for index, arg := range args { + if arg == "--" { + break + } + switch { + case arg == "-R" || arg == "--repo": + if index+1 >= len(args) || strings.TrimSpace(args[index+1]) == "" { + return "", true, fmt.Errorf("GitHub CLI repository argument is empty") + } + return args[index+1], true, nil + case strings.HasPrefix(arg, "--repo="): + return strings.TrimPrefix(arg, "--repo="), true, nil + case strings.HasPrefix(arg, "-R="): + return strings.TrimPrefix(arg, "-R="), true, nil + case strings.HasPrefix(arg, "-R") && len(arg) > 2: + return strings.TrimPrefix(arg, "-R"), true, nil + } + } + return "", false, nil +} + +func parseGitHubCLIRepository(raw, defaultHost string) (*githubCLIRepository, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("GitHub CLI repository is empty") + } + if strings.Contains(raw, "://") { + parsed, err := url.Parse(raw) + if err != nil || parsed.Hostname() == "" { + return nil, fmt.Errorf("GitHub CLI repository %q is invalid", raw) + } + return repositoryFromHostAndPath(parsed.Hostname(), parsed.Path, raw) + } + if before, path, found := strings.Cut(raw, ":"); found && strings.Contains(before, "@") { + host := before[strings.LastIndex(before, "@")+1:] + return repositoryFromHostAndPath(host, path, raw) + } + parts := strings.Split(strings.Trim(raw, "/"), "/") + if len(parts) == 2 { + if defaultHost == "" { + defaultHost = "github.com" + } + return repositoryFromHostAndPath(defaultHost, strings.Join(parts, "/"), raw) + } + if len(parts) == 3 { + return repositoryFromHostAndPath(parts[0], strings.Join(parts[1:], "/"), raw) + } + return nil, fmt.Errorf("GitHub CLI repository %q must be [HOST/]OWNER/REPO", raw) +} + +func repositoryFromHostAndPath(host, path, raw string) (*githubCLIRepository, error) { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) != 2 || strings.TrimSpace(host) == "" || parts[0] == "" || parts[1] == "" { + return nil, fmt.Errorf("GitHub CLI repository %q must be [HOST/]OWNER/REPO", raw) + } + repo := strings.TrimSuffix(parts[1], ".git") + if repo == "" { + return nil, fmt.Errorf("GitHub CLI repository %q must include a repository name", raw) + } + return &githubCLIRepository{ + host: strings.ToLower(strings.TrimSpace(host)), + owner: parts[0], + repo: repo, + }, nil +} + +func isolatedGitHubCLIConfigDir(taskID, sessionID string) (string, error) { + digest := sha256.Sum256([]byte(taskID + "\x00" + sessionID)) + dir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("kandev-gh-%x-", digest[:8])) + if err != nil { + return "", fmt.Errorf("create isolated gh config directory: %w", err) + } + if err := os.Chmod(dir, 0o700); err != nil { + _ = os.RemoveAll(dir) + return "", fmt.Errorf("secure isolated gh config directory: %w", err) + } + return dir, nil +} + +func replaceEnvironment(base []string, replacements map[string]string, remove ...string) []string { + dropped := make(map[string]struct{}, len(replacements)+len(remove)) + for key := range replacements { + dropped[strings.ToUpper(key)] = struct{}{} + } + for _, key := range remove { + dropped[strings.ToUpper(key)] = struct{}{} + } + result := make([]string, 0, len(base)+len(replacements)) + for _, entry := range base { + key, _, ok := strings.Cut(entry, "=") + if _, drop := dropped[strings.ToUpper(key)]; ok && drop { + continue + } + result = append(result, entry) + } + for key, value := range replacements { + result = append(result, key+"="+value) + } + return result +} + +func pathWithoutDirectory(path, excluded string) string { + cleanExcluded := filepath.Clean(excluded) + parts := filepath.SplitList(path) + filtered := parts[:0] + for _, part := range parts { + if filepath.Clean(part) != cleanExcluded { + filtered = append(filtered, part) + } + } + return strings.Join(filtered, string(os.PathListSeparator)) +} + +func githubCLIShimName() string { + if runtime.GOOS == windowsOS { + return "gh.exe" + } + return "gh" +} + +func isGitHubCLIShimInvocation(argv0 string) bool { + name := filepath.Base(strings.ReplaceAll(argv0, "\\", "/")) + return strings.EqualFold(name, "gh") || strings.EqualFold(name, "gh.exe") +} + +func installGitHubCLIShim(agentctlExecutable, tempRoot string) (string, func(), error) { + dir, err := os.MkdirTemp(tempRoot, "kandev-github-cli-") + if err != nil { + return "", nil, fmt.Errorf("create gh shim directory: %w", err) + } + cleanup := func() { _ = os.RemoveAll(dir) } + shim := filepath.Join(dir, githubCLIShimName()) + if err := linkOrCopyExecutable(agentctlExecutable, shim); err != nil { + cleanup() + return "", nil, fmt.Errorf("install gh shim: %w", err) + } + return dir, cleanup, nil +} + +func linkOrCopyExecutable(source, target string) error { + if err := os.Symlink(source, target); err == nil { + return nil + } + if err := os.Link(source, target); err == nil { + return nil + } + input, err := os.Open(source) + if err != nil { + return err + } + defer func() { _ = input.Close() }() + output, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o700) + if err != nil { + return err + } + _, copyErr := io.Copy(output, input) + closeErr := output.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} + +func lookPathIn(file, path string) (string, error) { + names := []string{file} + if runtime.GOOS == windowsOS && filepath.Ext(file) == "" { + names = []string{file + ".exe", file + ".cmd", file + ".bat", file} + } + for _, directory := range filepath.SplitList(path) { + for _, name := range names { + candidate := filepath.Join(directory, name) + info, err := os.Stat(candidate) + if err == nil && !info.IsDir() && (runtime.GOOS == windowsOS || info.Mode()&0o111 != 0) { + return candidate, nil + } + } + } + return "", exec.ErrNotFound +} + +func executeGitHubCLI( + ctx context.Context, + executable string, + args []string, + env []string, + stdin io.Reader, + stdout, stderr io.Writer, +) error { + cmd := exec.CommandContext(ctx, executable, args...) + cmd.Env = env + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} diff --git a/apps/backend/cmd/agentctl/github_cli_shim_test.go b/apps/backend/cmd/agentctl/github_cli_shim_test.go new file mode 100644 index 0000000000..03838df194 --- /dev/null +++ b/apps/backend/cmd/agentctl/github_cli_shim_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestInstallGitHubCLIShimCreatesGHEntrypoint(t *testing.T) { + root := t.TempDir() + binary := filepath.Join(root, "agentctl") + if err := os.WriteFile(binary, []byte("agentctl"), 0o700); err != nil { + t.Fatal(err) + } + + shimDir, cleanup, err := installGitHubCLIShim(binary, root) + if err != nil { + t.Fatalf("installGitHubCLIShim() error = %v", err) + } + t.Cleanup(cleanup) + entrypoint := filepath.Join(shimDir, githubCLIShimName()) + if _, err := os.Stat(entrypoint); err != nil { + t.Fatalf("stat gh shim: %v", err) + } + if filepath.Dir(entrypoint) != shimDir { + t.Fatalf("entrypoint = %q, want inside %q", entrypoint, shimDir) + } +} + +func TestPathWithoutDirectoryPreservesPathList(t *testing.T) { + path := strings.Join([]string{"/bin", "/shim", "/usr/bin"}, string(os.PathListSeparator)) + want := strings.Join([]string{"/bin", "/usr/bin"}, string(os.PathListSeparator)) + if got := pathWithoutDirectory(path, "/shim"); got != want { + t.Fatalf("pathWithoutDirectory() = %q, want %q", got, want) + } +} + +func TestIsGitHubCLIShimInvocation(t *testing.T) { + for _, name := range []string{"/tmp/shims/gh", `C:\\shims\\gh.exe`} { + if !isGitHubCLIShimInvocation(name) { + t.Fatalf("isGitHubCLIShimInvocation(%q) = false", name) + } + } + if isGitHubCLIShimInvocation("/usr/local/bin/agentctl") { + t.Fatal("agentctl executable was mistaken for gh shim") + } +} + +func TestGitHubCLIShimRefreshesAndIsolatesEachInvocation(t *testing.T) { + issued := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + issued++ + _ = json.NewEncoder(w).Encode(map[string]string{ + "username": "x-access-token", + "password": "fresh-token-" + string(rune('0'+issued)), + }) + })) + t.Cleanup(server.Close) + env := githubCredentialTestEnv(server.URL) + env["PATH"] = "/shim:/usr/bin" + env["GITHUB_TOKEN"] = "parent-token-must-not-win" + var childTokens []string + var configDirs []string + runner := func(_ context.Context, executable string, args []string, childEnv []string, _ io.Reader, _, _ io.Writer) error { + if executable != "/usr/bin/gh" { + t.Errorf("executable = %q, want /usr/bin/gh", executable) + } + if len(args) != 2 || args[0] != "pr" || args[1] != "list" { + t.Errorf("args = %v", args) + } + childTokens = append(childTokens, envValue(childEnv, "GH_TOKEN")) + configDir := envValue(childEnv, "GH_CONFIG_DIR") + configDirs = append(configDirs, configDir) + if _, err := os.Stat(configDir); err != nil { + t.Errorf("GH_CONFIG_DIR was not available to gh: %v", err) + } + if got := envValue(childEnv, "GITHUB_TOKEN"); got != "" { + t.Errorf("child GITHUB_TOKEN = %q, want removed", got) + } + return nil + } + lookPath := func(file, path string) (string, error) { + if file != "gh" || path != "/usr/bin" { + t.Fatalf("lookPath(%q, %q)", file, path) + } + return "/usr/bin/gh", nil + } + + for range 2 { + if err := runGitHubCLIShim( + context.Background(), []string{"pr", "list"}, strings.NewReader(""), io.Discard, io.Discard, + lookupEnv(env), func() []string { return envMap(env) }, server.Client(), "/shim", lookPath, runner, + ); err != nil { + t.Fatalf("runGitHubCLIShim() error = %v", err) + } + } + if got, want := strings.Join(childTokens, ","), "fresh-token-1,fresh-token-2"; got != want { + t.Fatalf("child GH_TOKEN values = %q, want %q", got, want) + } + if configDirs[0] == "" || configDirs[0] == configDirs[1] { + t.Fatalf("GH_CONFIG_DIR values = %v, want unique isolated directories", configDirs) + } + for _, configDir := range configDirs { + if _, err := os.Stat(configDir); !os.IsNotExist(err) { + t.Fatalf("GH_CONFIG_DIR %q remains after invocation", configDir) + } + } +} + +func TestGitHubCLIShimSelectsRepositoryLease(t *testing.T) { + var got githubBrokerResolveRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Errorf("decode request: %v", err) + } + _, _ = io.WriteString(w, `{"username":"x-access-token","password":"backend-token"}`) + })) + t.Cleanup(server.Close) + env := githubCredentialTestEnv(server.URL) + env["PATH"] = "/shim:/usr/bin" + env["GH_REPO"] = "acme/backend" + env[envGitHubCredentialScopes] = `[ + {"lease":"frontend-lease","task_id":"task-1","session_id":"session-1","repository_id":"repo-1","owner":"acme","repo":"frontend","host":"github.com"}, + {"lease":"backend-lease","task_id":"task-1","session_id":"session-1","repository_id":"repo-2","owner":"acme","repo":"backend","host":"github.com"} + ]` + var childToken string + err := runGitHubCLIShim( + context.Background(), []string{"pr", "list"}, strings.NewReader(""), io.Discard, io.Discard, + lookupEnv(env), func() []string { return envMap(env) }, server.Client(), "/shim", + func(string, string) (string, error) { return "/usr/bin/gh", nil }, + func(_ context.Context, _ string, _ []string, childEnv []string, _ io.Reader, _, _ io.Writer) error { + childToken = envValue(childEnv, "GH_TOKEN") + return nil + }, + ) + if err != nil { + t.Fatalf("runGitHubCLIShim() error = %v", err) + } + if got.Lease != "backend-lease" || got.RepositoryID != "repo-2" { + t.Fatalf("selected broker scope = %+v", got) + } + if childToken != "backend-token" { + t.Fatalf("child GH_TOKEN = %q, want backend-token", childToken) + } +} + +func TestParseGitHubCLIRepository(t *testing.T) { + tests := []struct { + name string + raw string + defaultHost string + want githubCLIRepository + }{ + {name: "owner and repo", raw: "acme/widgets", defaultHost: "github.com", want: githubCLIRepository{host: "github.com", owner: "acme", repo: "widgets"}}, + {name: "enterprise", raw: "github.example.com/acme/widgets", want: githubCLIRepository{host: "github.example.com", owner: "acme", repo: "widgets"}}, + {name: "HTTPS remote", raw: "https://github.com/acme/widgets.git", want: githubCLIRepository{host: "github.com", owner: "acme", repo: "widgets"}}, + {name: "SSH remote", raw: "git@github.com:acme/widgets.git", want: githubCLIRepository{host: "github.com", owner: "acme", repo: "widgets"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := parseGitHubCLIRepository(test.raw, test.defaultHost) + if err != nil { + t.Fatalf("parseGitHubCLIRepository() error = %v", err) + } + if *got != test.want { + t.Fatalf("parseGitHubCLIRepository() = %+v, want %+v", *got, test.want) + } + }) + } +} + +func TestGitHubCLIRepositoryArgument(t *testing.T) { + for _, args := range [][]string{ + {"pr", "list", "-R", "acme/widgets"}, + {"pr", "list", "--repo=acme/widgets"}, + {"pr", "list", "-Racme/widgets"}, + } { + got, found, err := githubCLIRepositoryArgument(args) + if err != nil || !found || got != "acme/widgets" { + t.Fatalf("githubCLIRepositoryArgument(%v) = %q, %v, %v", args, got, found, err) + } + } +} + +func envMap(env map[string]string) []string { + result := make([]string, 0, len(env)) + for key, value := range env { + result = append(result, key+"="+value) + } + return result +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + return strings.TrimPrefix(entry, prefix) + } + } + return "" +} diff --git a/apps/backend/cmd/agentctl/github_credential.go b/apps/backend/cmd/agentctl/github_credential.go new file mode 100644 index 0000000000..3a6b2c5773 --- /dev/null +++ b/apps/backend/cmd/agentctl/github_credential.go @@ -0,0 +1,222 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/kandev/kandev/internal/githubauth" +) + +type githubBrokerResolveRequest struct { + Lease string `json:"lease"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + RepositoryID string `json:"repository_id"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Host string `json:"host"` +} + +type githubBrokerCredential struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type githubCredentialBrokerClient struct { + endpoint string + request githubBrokerResolveRequest + http *http.Client +} + +func newGitHubCredentialBrokerClient( + getenv func(string) string, + httpClient *http.Client, +) (*githubCredentialBrokerClient, error) { + request := githubBrokerResolveRequest{ + Lease: strings.TrimSpace(getenv(githubauth.CredentialLeaseEnv)), + TaskID: strings.TrimSpace(getenv(githubauth.CredentialTaskIDEnv)), + SessionID: strings.TrimSpace(getenv(githubauth.CredentialSessionIDEnv)), + RepositoryID: strings.TrimSpace(getenv(githubauth.CredentialRepositoryEnv)), + Owner: strings.TrimSpace(getenv(githubauth.CredentialOwnerEnv)), + Repo: strings.TrimSuffix(strings.TrimSpace(getenv(githubauth.CredentialRepoEnv)), ".git"), + Host: strings.ToLower(strings.TrimSpace(getenv(githubauth.CredentialHostEnv))), + } + endpoint := strings.TrimSpace(getenv(githubauth.CredentialBrokerURLEnv)) + for name, value := range map[string]string{ + "broker URL": endpoint, "lease": request.Lease, "task": request.TaskID, + "session": request.SessionID, "repository": request.RepositoryID, + "owner": request.Owner, "repo": request.Repo, "host": request.Host, + } { + if value == "" { + return nil, fmt.Errorf("GitHub credential %s is not configured", name) + } + } + parsed, err := url.ParseRequestURI(endpoint) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return nil, fmt.Errorf("GitHub credential broker URL is invalid") + } + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &githubCredentialBrokerClient{endpoint: endpoint, request: request, http: httpClient}, nil +} + +func (c *githubCredentialBrokerClient) resolve(ctx context.Context) (*githubBrokerCredential, error) { + body, err := json.Marshal(c.request) + if err != nil { + return nil, fmt.Errorf("encode GitHub credential request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create GitHub credential request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("resolve GitHub credential: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + return nil, fmt.Errorf("resolve GitHub credential: broker returned HTTP %d", resp.StatusCode) + } + var credential githubBrokerCredential + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&credential); err != nil { + return nil, fmt.Errorf("decode GitHub credential: %w", err) + } + if credential.Username == "" || credential.Password == "" { + return nil, fmt.Errorf("resolve GitHub credential: broker returned an empty credential") + } + return &credential, nil +} + +func runGitHubCredentialHelper( + ctx context.Context, + args []string, + stdin io.Reader, + stdout io.Writer, + getenv func(string) string, + httpClient *http.Client, +) error { + if len(args) != 1 { + return fmt.Errorf("usage: agentctl git-credential ") + } + switch args[0] { + case "store", "erase": + return nil + case "get": + default: + return fmt.Errorf("unsupported git credential operation %q", args[0]) + } + + input, err := readGitCredentialInput(stdin) + if err != nil { + return err + } + client, err := newGitHubCredentialBrokerClientForInput(getenv, httpClient, input) + if err != nil { + return err + } + if err := validateGitCredentialScope(input, client.request); err != nil { + return err + } + credential, err := client.resolve(ctx) + if err != nil { + return err + } + _, err = fmt.Fprintf(stdout, "username=%s\npassword=%s\n\n", credential.Username, credential.Password) + return err +} + +func newGitHubCredentialBrokerClientForInput( + getenv func(string) string, + httpClient *http.Client, + input map[string]string, +) (*githubCredentialBrokerClient, error) { + rawScopes := strings.TrimSpace(getenv(githubauth.CredentialScopesEnv)) + if rawScopes == "" { + return newGitHubCredentialBrokerClient(getenv, httpClient) + } + var scopes []githubBrokerResolveRequest + if err := json.Unmarshal([]byte(rawScopes), &scopes); err != nil { + return nil, fmt.Errorf("GitHub credential scopes are invalid: %w", err) + } + for _, scope := range scopes { + if validateGitCredentialScope(input, scope) == nil { + return newGitHubCredentialBrokerClientForRequest(getenv, httpClient, scope) + } + } + return nil, fmt.Errorf("git repository does not match any credential lease scope") +} + +func newGitHubCredentialBrokerClientForRequest( + getenv func(string) string, + httpClient *http.Client, + request githubBrokerResolveRequest, +) (*githubCredentialBrokerClient, error) { + endpoint := strings.TrimSpace(getenv(githubauth.CredentialBrokerURLEnv)) + for name, value := range map[string]string{ + "broker URL": endpoint, "lease": request.Lease, "task": request.TaskID, + "session": request.SessionID, "repository": request.RepositoryID, + "owner": request.Owner, "repo": request.Repo, "host": request.Host, + } { + if strings.TrimSpace(value) == "" { + return nil, fmt.Errorf("GitHub credential %s is not configured", name) + } + } + parsed, err := url.ParseRequestURI(endpoint) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return nil, fmt.Errorf("GitHub credential broker URL is invalid") + } + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &githubCredentialBrokerClient{endpoint: endpoint, request: request, http: httpClient}, nil +} + +func readGitCredentialInput(input io.Reader) (map[string]string, error) { + values := make(map[string]string) + scanner := bufio.NewScanner(io.LimitReader(input, 64<<10)) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + break + } + key, value, ok := strings.Cut(line, "=") + if ok { + values[key] = value + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read git credential request: %w", err) + } + return values, nil +} + +func validateGitCredentialScope(input map[string]string, scope githubBrokerResolveRequest) error { + protocol := strings.TrimSpace(input["protocol"]) + if !strings.EqualFold(protocol, "https") { + return fmt.Errorf("git credential protocol %q is not supported", protocol) + } + host := strings.TrimSpace(input["host"]) + if host == "" || !strings.EqualFold(host, scope.Host) { + return fmt.Errorf("git credential host does not match credential lease scope") + } + path, err := url.PathUnescape(strings.Trim(input["path"], "/")) + if err != nil { + return fmt.Errorf("decode git credential path: %w", err) + } + path = strings.TrimSuffix(path, ".git") + if path == "" || !strings.EqualFold(path, scope.Owner+"/"+scope.Repo) { + return fmt.Errorf("git repository does not match credential lease scope") + } + return nil +} diff --git a/apps/backend/cmd/agentctl/github_credential_test.go b/apps/backend/cmd/agentctl/github_credential_test.go new file mode 100644 index 0000000000..24a2a8a95f --- /dev/null +++ b/apps/backend/cmd/agentctl/github_credential_test.go @@ -0,0 +1,166 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/kandev/kandev/internal/githubauth" +) + +const ( + envGitHubCredentialBrokerURL = githubauth.CredentialBrokerURLEnv + envGitHubCredentialLease = githubauth.CredentialLeaseEnv + envGitHubCredentialTaskID = githubauth.CredentialTaskIDEnv + envGitHubCredentialSessionID = githubauth.CredentialSessionIDEnv + envGitHubCredentialRepository = githubauth.CredentialRepositoryEnv + envGitHubCredentialOwner = githubauth.CredentialOwnerEnv + envGitHubCredentialRepo = githubauth.CredentialRepoEnv + envGitHubCredentialHost = githubauth.CredentialHostEnv + envGitHubCredentialScopes = githubauth.CredentialScopesEnv +) + +func TestGitHubCredentialHelperGetsFreshCredential(t *testing.T) { + var got githubBrokerResolveRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Errorf("decode request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"username":"x-access-token","password":"fresh-token"}`) + })) + t.Cleanup(server.Close) + env := githubCredentialTestEnv(server.URL) + var stdout bytes.Buffer + + err := runGitHubCredentialHelper( + context.Background(), + []string{"get"}, + strings.NewReader("protocol=https\nhost=github.com\npath=acme/widgets.git\n\n"), + &stdout, + lookupEnv(env), + server.Client(), + ) + if err != nil { + t.Fatalf("runGitHubCredentialHelper() error = %v", err) + } + if got.Lease != "opaque-lease" || got.TaskID != "task-1" || got.SessionID != "session-1" { + t.Fatalf("broker scope = %+v", got) + } + if got.RepositoryID != "repo-1" || got.Owner != "acme" || got.Repo != "widgets" || got.Host != "github.com" { + t.Fatalf("broker repository scope = %+v", got) + } + if want := "username=x-access-token\npassword=fresh-token\n\n"; stdout.String() != want { + t.Fatalf("credential helper output = %q, want %q", stdout.String(), want) + } +} + +func TestGitHubCredentialHelperRejectsRepositoryScopeMismatch(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + env := githubCredentialTestEnv(server.URL) + + err := runGitHubCredentialHelper( + context.Background(), + []string{"get"}, + strings.NewReader("protocol=https\nhost=github.com\npath=other/repository.git\n\n"), + io.Discard, + lookupEnv(env), + server.Client(), + ) + if err == nil || !strings.Contains(err.Error(), "does not match credential lease scope") { + t.Fatalf("runGitHubCredentialHelper() error = %v, want scope mismatch", err) + } + if requests != 0 { + t.Fatalf("broker requests = %d, want 0", requests) + } +} + +func TestGitHubCredentialHelperRequiresCompleteScope(t *testing.T) { + for name, input := range map[string]string{ + "protocol": "host=github.com\npath=acme/widgets.git\n\n", + "host": "protocol=https\npath=acme/widgets.git\n\n", + "path": "protocol=https\nhost=github.com\n\n", + } { + t.Run(name, func(t *testing.T) { + env := githubCredentialTestEnv("https://broker.example/resolve") + err := runGitHubCredentialHelper( + context.Background(), []string{"get"}, strings.NewReader(input), io.Discard, + lookupEnv(env), http.DefaultClient, + ) + if err == nil { + t.Fatal("runGitHubCredentialHelper() error = nil, want incomplete scope rejection") + } + }) + } +} + +func TestGitHubCredentialHelperSelectsRepositoryLease(t *testing.T) { + var got githubBrokerResolveRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Errorf("decode request: %v", err) + } + _, _ = io.WriteString(w, `{"username":"x-access-token","password":"fresh-token"}`) + })) + t.Cleanup(server.Close) + env := githubCredentialTestEnv(server.URL) + env[envGitHubCredentialScopes] = `[ + {"lease":"frontend-lease","task_id":"task-1","session_id":"session-1","repository_id":"repo-1","owner":"acme","repo":"frontend","host":"github.com"}, + {"lease":"backend-lease","task_id":"task-1","session_id":"session-1","repository_id":"repo-2","owner":"acme","repo":"backend","host":"github.com"} + ]` + + err := runGitHubCredentialHelper( + context.Background(), []string{"get"}, + strings.NewReader("protocol=https\nhost=github.com\npath=acme/backend.git\n\n"), + io.Discard, lookupEnv(env), server.Client(), + ) + if err != nil { + t.Fatalf("runGitHubCredentialHelper() error = %v", err) + } + if got.Lease != "backend-lease" || got.RepositoryID != "repo-2" { + t.Fatalf("selected broker scope = %+v", got) + } +} + +func TestGitHubCredentialHelperIgnoresStoreAndErase(t *testing.T) { + for _, operation := range []string{"store", "erase"} { + t.Run(operation, func(t *testing.T) { + if err := runGitHubCredentialHelper( + context.Background(), []string{operation}, strings.NewReader("password=secret\n\n"), + io.Discard, lookupEnv(nil), http.DefaultClient, + ); err != nil { + t.Fatalf("runGitHubCredentialHelper(%q) error = %v", operation, err) + } + }) + } +} + +func githubCredentialTestEnv(url string) map[string]string { + return map[string]string{ + envGitHubCredentialBrokerURL: url, + envGitHubCredentialLease: "opaque-lease", + envGitHubCredentialTaskID: "task-1", + envGitHubCredentialSessionID: "session-1", + envGitHubCredentialRepository: "repo-1", + envGitHubCredentialOwner: "acme", + envGitHubCredentialRepo: "widgets", + envGitHubCredentialHost: "github.com", + } +} + +func lookupEnv(env map[string]string) func(string) string { + return func(key string) string { return env[key] } +} diff --git a/apps/backend/cmd/agentctl/main.go b/apps/backend/cmd/agentctl/main.go index 7498fc4b53..12ef23331a 100644 --- a/apps/backend/cmd/agentctl/main.go +++ b/apps/backend/cmd/agentctl/main.go @@ -9,13 +9,16 @@ package main import ( "context" + "errors" "flag" "fmt" "net" "net/http" "os" + "os/exec" "os/signal" "strconv" + "strings" "syscall" "time" @@ -25,6 +28,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/instance" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/githubauth" mcpserver "github.com/kandev/kandev/internal/mcp/server" "github.com/kandev/kandev/pkg/agent" "go.uber.org/zap" @@ -39,6 +43,10 @@ var ( ) func main() { + if code, handled := runGitHubUtilityCommand(); handled { + os.Exit(code) + } + // Dispatch kandev CLI subcommands before flag parsing or server startup. // When invoked as "agentctl kandev ", this runs the CLI and exits // without starting the HTTP server. @@ -46,6 +54,13 @@ func main() { os.Exit(runKandevCLI(os.Args[2:])) } + cleanupGitHubCLIShim, err := prepareGitHubCLIShim() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer cleanupGitHubCLIShim() + flag.Parse() // Load configuration @@ -86,6 +101,60 @@ func main() { run(cfg, log) } +func runGitHubUtilityCommand() (int, bool) { + if isGitHubCLIShimInvocation(os.Args[0]) { + err := runGitHubCLIShim( + context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr, + os.Getenv, os.Environ, nil, os.Getenv(envGitHubCLIShimDir), lookPathIn, executeGitHubCLI, + ) + return githubUtilityExitCode(err), true + } + if len(os.Args) > 1 && os.Args[1] == "git-credential" { + err := runGitHubCredentialHelper( + context.Background(), os.Args[2:], os.Stdin, os.Stdout, os.Getenv, nil, + ) + return githubUtilityExitCode(err), true + } + return 0, false +} + +func prepareGitHubCLIShim() (func(), error) { + if os.Getenv(githubauth.CredentialBrokerURLEnv) == "" { + return func() {}, nil + } + executable, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("resolve agentctl executable for gh shim: %w", err) + } + shimDir, cleanup, err := installGitHubCLIShim(executable, "") + if err != nil { + return nil, err + } + if err := os.Setenv(envGitHubCLIShimDir, shimDir); err != nil { + cleanup() + return nil, fmt.Errorf("configure gh shim directory: %w", err) + } + path := pathWithoutDirectory(os.Getenv("PATH"), shimDir) + path = strings.Join([]string{shimDir, path}, string(os.PathListSeparator)) + if err := os.Setenv("PATH", path); err != nil { + cleanup() + return nil, fmt.Errorf("configure gh shim PATH: %w", err) + } + return cleanup, nil +} + +func githubUtilityExitCode(err error) int { + if err == nil { + return 0 + } + fmt.Fprintln(os.Stderr, err) + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode() + } + return 1 +} + // run starts the agentctl server. // All instances are managed through the instance management API. func run(cfg *config.Config, log *logger.Logger) { diff --git a/apps/backend/internal/agent/remoteauth/catalog.go b/apps/backend/internal/agent/remoteauth/catalog.go index c6465134cf..35ccaebd18 100644 --- a/apps/backend/internal/agent/remoteauth/catalog.go +++ b/apps/backend/internal/agent/remoteauth/catalog.go @@ -11,12 +11,10 @@ import ( ) const ( - ghCLISpecID = "gh_cli" - ghCLITokenMethodID = "gh_cli_token" - ghCLIEnvMethodID = "gh_cli_env" - ghCLIEnvVar = "GITHUB_TOKEN" - ghCLIDisplayName = "GitHub CLI" - ghCLITokenMethodType = "gh_cli_token" + ghCLISpecID = "gh_cli" + ghCLIEnvMethodID = "gh_cli_env" + ghCLIEnvVar = "GITHUB_TOKEN" + ghCLIDisplayName = "GitHub CLI" ) // Method describes one selectable remote authentication method. @@ -60,12 +58,6 @@ func BuildCatalogForHost(enabledAgents []agents.Agent, currentOS, homeDir string ID: ghCLISpecID, DisplayName: ghCLIDisplayName, Methods: []Method{ - { - MethodID: ghCLITokenMethodID, - Type: ghCLITokenMethodType, - Label: "Copy token from local gh CLI", - SetupHint: "Runs `gh auth token` on your machine and sets GITHUB_TOKEN in the remote environment", - }, { MethodID: ghCLIEnvMethodID, Type: "env", diff --git a/apps/backend/internal/agent/remoteauth/catalog_test.go b/apps/backend/internal/agent/remoteauth/catalog_test.go index 5e5f39af90..34d7673213 100644 --- a/apps/backend/internal/agent/remoteauth/catalog_test.go +++ b/apps/backend/internal/agent/remoteauth/catalog_test.go @@ -37,3 +37,28 @@ func TestBuildCatalog_MockSpecSerializesEmptyMethodsAsArray(t *testing.T) { t.Errorf("expected `\"methods\":[]` in JSON, got: %s", out) } } + +func TestBuildCatalog_GitHubCLIOnlyOffersExplicitSecret(t *testing.T) { + cat := BuildCatalogForHost(nil, "linux", "") + + var githubCLI *Spec + for i := range cat.Specs { + if cat.Specs[i].ID == ghCLISpecID { + githubCLI = &cat.Specs[i] + break + } + } + if githubCLI == nil { + t.Fatal("GitHub CLI auth spec missing") + } + if len(githubCLI.Methods) != 1 { + t.Fatalf("GitHub CLI methods = %+v, want only explicit secret method", githubCLI.Methods) + } + method := githubCLI.Methods[0] + if method.MethodID != ghCLIEnvMethodID || method.Type != "env" || method.EnvVar != ghCLIEnvVar { + t.Fatalf("GitHub CLI method = %+v, want explicit GITHUB_TOKEN secret", method) + } + if _, ok := cat.FindMethod("gh_cli_token"); ok { + t.Fatal("host-global gh CLI token method must not be selectable") + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/broker_reachability.go b/apps/backend/internal/agent/runtime/lifecycle/broker_reachability.go new file mode 100644 index 0000000000..60630a06eb --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/broker_reachability.go @@ -0,0 +1,122 @@ +package lifecycle + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + agentctltypes "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/githubauth" +) + +var ErrGitHubCredentialBrokerUnreachable = errors.New("GitHub credential broker is unreachable from executor") + +const brokerReachabilityScript = `if ! command -v curl >/dev/null 2>&1; then + echo "curl is required to reach the Kandev GitHub credential broker" >&2 + exit 69 +fi +broker_status=$(curl -sS --connect-timeout 5 --max-time 10 -o /dev/null -w '%{http_code}' "$KANDEV_GITHUB_CREDENTIAL_BROKER_URL") || exit 69 +if [ "$broker_status" != "204" ]; then + echo "Kandev GitHub credential broker readiness returned HTTP $broker_status" >&2 + exit 69 +fi` + +type brokerCommandRunner func(context.Context, string, map[string]string) ([]byte, error) + +func runBrokerReachabilityPreflight( + ctx context.Context, + env map[string]string, + run brokerCommandRunner, +) error { + if !hasManagedGitHubBrokerEnv(env) { + return nil + } + probeEnv := map[string]string{ + githubauth.CredentialBrokerURLEnv: env[githubauth.CredentialBrokerURLEnv], + } + output, err := run(ctx, brokerReachabilityScript, probeEnv) + if err != nil { + return fmt.Errorf("%w: %s: %v", ErrGitHubCredentialBrokerUnreachable, + strings.TrimSpace(string(output)), err) + } + return nil +} + +type brokerAgentctlProcessClient interface { + StartProcess(context.Context, agentctl.StartProcessRequest) (*agentctl.ProcessInfo, error) + GetProcess(context.Context, string, bool) (*agentctl.ProcessInfo, error) +} + +func runBrokerReachabilityViaAgentctl( + ctx context.Context, + client brokerAgentctlProcessClient, + sessionID string, + env map[string]string, +) error { + return runBrokerReachabilityPreflight(ctx, env, func( + ctx context.Context, + command string, + probeEnv map[string]string, + ) ([]byte, error) { + process, err := client.StartProcess(ctx, agentctl.StartProcessRequest{ + SessionID: sessionID, + Kind: agentctltypes.ProcessKindCustom, + Command: command, + Env: probeEnv, + }) + if err != nil { + return nil, err + } + return waitBrokerReachabilityProcess(ctx, client, process) + }) +} + +func waitBrokerReachabilityProcess( + ctx context.Context, + client brokerAgentctlProcessClient, + process *agentctl.ProcessInfo, +) ([]byte, error) { + if process == nil { + return nil, errors.New("agentctl returned no process") + } + deadline := time.NewTimer(15 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + if output, done, err := brokerProcessResult(process); done { + return output, err + } + select { + case <-ctx.Done(): + return nil, context.Cause(ctx) + case <-deadline.C: + return nil, errors.New("broker reachability process timed out") + case <-ticker.C: + var err error + process, err = client.GetProcess(ctx, process.ID, true) + if err != nil { + return nil, err + } + } + } +} + +func brokerProcessResult(process *agentctl.ProcessInfo) ([]byte, bool, error) { + if process.Status != agentctltypes.ProcessStatusExited && + process.Status != agentctltypes.ProcessStatusFailed && + process.Status != agentctltypes.ProcessStatusStopped { + return nil, false, nil + } + var output strings.Builder + for _, chunk := range process.Output { + output.WriteString(chunk.Data) + } + if process.ExitCode == nil || *process.ExitCode != 0 || process.Status != agentctltypes.ProcessStatusExited { + return []byte(output.String()), true, fmt.Errorf("probe process status %s", process.Status) + } + return []byte(output.String()), true, nil +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/broker_reachability_test.go b/apps/backend/internal/agent/runtime/lifecycle/broker_reachability_test.go new file mode 100644 index 0000000000..5c075cf4e6 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/broker_reachability_test.go @@ -0,0 +1,155 @@ +package lifecycle + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + agentctltypes "github.com/kandev/kandev/internal/agentctl/types" +) + +type terminalBrokerProcessClient struct { + exitCode int + status agentctl.ProcessStatus + request agentctl.StartProcessRequest +} + +func (c *terminalBrokerProcessClient) StartProcess( + _ context.Context, + req agentctl.StartProcessRequest, +) (*agentctl.ProcessInfo, error) { + c.request = req + return &agentctl.ProcessInfo{ + ID: "probe-1", Status: c.status, ExitCode: &c.exitCode, + }, nil +} + +func (c *terminalBrokerProcessClient) GetProcess( + context.Context, + string, + bool, +) (*agentctl.ProcessInfo, error) { + return nil, errors.New("terminal process must not be polled") +} + +func TestBrokerReachabilityPreflightFailsClosedFromExecutorRunner(t *testing.T) { + env := map[string]string{ + envKeyGitHubCredentialBrokerURL: "https://unreachable.example/resolve", + envKeyGitHubCredentialLease: "secret-lease", + } + runnerErr := errors.New("network unreachable") + err := runBrokerReachabilityPreflight(context.Background(), env, func( + _ context.Context, + command string, + probeEnv map[string]string, + ) ([]byte, error) { + if !strings.Contains(command, "curl") { + t.Fatalf("probe command = %q", command) + } + if probeEnv[envKeyGitHubCredentialBrokerURL] != env[envKeyGitHubCredentialBrokerURL] { + t.Fatalf("probe URL env = %#v", probeEnv) + } + if len(probeEnv) != 1 || strings.Contains(command, "secret-lease") { + t.Fatalf("probe leaked broker credential: command=%q env=%#v", command, probeEnv) + } + return []byte("curl: connection refused"), runnerErr + }) + if !errors.Is(err, ErrGitHubCredentialBrokerUnreachable) { + t.Fatalf("preflight error = %v, want unreachable", err) + } +} + +func TestBrokerReachabilityPreflightAllowsReachableAndExplicitTokenExecutors(t *testing.T) { + calls := 0 + reachable := map[string]string{ + envKeyGitHubCredentialBrokerURL: "https://reachable.example/resolve", + envKeyGitHubCredentialLease: "lease", + } + if err := runBrokerReachabilityPreflight(context.Background(), reachable, + func(context.Context, string, map[string]string) ([]byte, error) { + calls++ + return nil, nil + }); err != nil { + t.Fatalf("reachable preflight error = %v", err) + } + if err := runBrokerReachabilityPreflight(context.Background(), map[string]string{ + "GITHUB_TOKEN": "explicit-profile-token", + }, func(context.Context, string, map[string]string) ([]byte, error) { + calls++ + return nil, errors.New("must not run") + }); err != nil { + t.Fatalf("explicit token preflight error = %v", err) + } + if calls != 1 { + t.Fatalf("runner calls = %d, want 1", calls) + } +} + +func TestBrokerReachabilityPreflightRequiresExactReadyResponse(t *testing.T) { + binDir := t.TempDir() + curlPath := filepath.Join(binDir, "curl") + if err := os.WriteFile(curlPath, []byte("#!/bin/sh\nprintf '%s' \"$FAKE_CURL_STATUS\"\n"), 0o700); err != nil { + t.Fatal(err) + } + env := map[string]string{ + envKeyGitHubCredentialBrokerURL: "https://kandev.example/api/v1/github/credentials/resolve", + envKeyGitHubCredentialLease: "lease-must-not-be-forwarded", + } + for _, test := range []struct { + name string + status string + wantOK bool + }{ + {name: "ready", status: "204", wantOK: true}, + {name: "wrong route", status: "404"}, + {name: "broker failure", status: "500"}, + {name: "redirect", status: "301"}, + } { + t.Run(test.name, func(t *testing.T) { + err := runBrokerReachabilityPreflight(context.Background(), env, func( + ctx context.Context, + command string, + probeEnv map[string]string, + ) ([]byte, error) { + cmd := exec.CommandContext(ctx, "sh", "-c", command) + cmd.Env = append(os.Environ(), + "PATH="+binDir+":"+os.Getenv("PATH"), + envKeyGitHubCredentialBrokerURL+"="+probeEnv[envKeyGitHubCredentialBrokerURL], + "FAKE_CURL_STATUS="+test.status, + ) + return cmd.CombinedOutput() + }) + if test.wantOK && err != nil { + t.Fatalf("preflight error = %v", err) + } + if !test.wantOK && !errors.Is(err, ErrGitHubCredentialBrokerUnreachable) { + t.Fatalf("preflight error = %v, want unreachable", err) + } + }) + } +} + +func TestBrokerReachabilityViaAgentctlUsesExecutorProcessResult(t *testing.T) { + env := map[string]string{ + envKeyGitHubCredentialBrokerURL: "https://kandev.example/resolve", + envKeyGitHubCredentialLease: "lease-must-not-be-forwarded", + } + reachable := &terminalBrokerProcessClient{status: agentctltypes.ProcessStatusExited} + if err := runBrokerReachabilityViaAgentctl(context.Background(), reachable, "session-1", env); err != nil { + t.Fatalf("reachable agentctl preflight error = %v", err) + } + if len(reachable.request.Env) != 1 || reachable.request.Env[envKeyGitHubCredentialBrokerURL] == "" { + t.Fatalf("agentctl probe env = %#v", reachable.request.Env) + } + + unreachable := &terminalBrokerProcessClient{status: agentctltypes.ProcessStatusFailed, exitCode: 7} + err := runBrokerReachabilityViaAgentctl(context.Background(), unreachable, "session-1", env) + if !errors.Is(err, ErrGitHubCredentialBrokerUnreachable) { + t.Fatalf("unreachable agentctl preflight error = %v", err) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/container.go b/apps/backend/internal/agent/runtime/lifecycle/container.go index be72a0cd3c..471e15001a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/container.go +++ b/apps/backend/internal/agent/runtime/lifecycle/container.go @@ -480,7 +480,14 @@ func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker //nolint:dupword // two `fi` tokens close two distinct shell blocks. bootstrap := []string{ "sh", "-c", - `if [ -n "$KANDEV_PREPARE_SCRIPT" ]; then + `if [ -n "${KANDEV_GITHUB_CREDENTIAL_BROKER_URL:-}" ] && [ -n "${KANDEV_GITHUB_CREDENTIAL_LEASE:-}" ]; then +` + brokerReachabilityScript + ` + probe_rc=$? + if [ "$probe_rc" -ne 0 ]; then + exit "$probe_rc" + fi +fi +if [ -n "$KANDEV_PREPARE_SCRIPT" ]; then (eval "$KANDEV_PREPARE_SCRIPT") prep_rc=$? if [ "$prep_rc" -ne 0 ]; then diff --git a/apps/backend/internal/agent/runtime/lifecycle/container_config_test.go b/apps/backend/internal/agent/runtime/lifecycle/container_config_test.go index 25f2a0904b..a59078c752 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/container_config_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/container_config_test.go @@ -94,6 +94,37 @@ func TestBuildContainerConfig_WorkingDirIsAlwaysContainerPath(t *testing.T) { } } +func TestBuildContainerConfigPreflightsBrokerBeforePrepareClone(t *testing.T) { + cm := newCMTest(t) + cfg := ContainerConfig{ + AgentConfig: newConfigStubAgent(), + InstanceID: "0123456789abcdef", + TaskID: "task-1", + Credentials: map[string]string{ + envKeyGitHubCredentialBrokerURL: "https://kandev.example/api/v1/github/credentials/resolve", + envKeyGitHubCredentialLease: "lease", + }, + PrepareScript: "git clone https://github.com/acme/widgets.git /workspace", + } + + got, err := cm.buildContainerConfig(cfg) + if err != nil { + t.Fatalf("buildContainerConfig: %v", err) + } + if len(got.Entrypoint) != 3 { + t.Fatalf("entrypoint = %#v", got.Entrypoint) + } + script := got.Entrypoint[2] + probeAt := strings.Index(script, "curl -sS --connect-timeout") + cloneAt := strings.Index(script, `eval "$KANDEV_PREPARE_SCRIPT"`) + if probeAt < 0 || cloneAt < 0 || probeAt >= cloneAt { + t.Fatalf("broker probe must precede prepare/clone: %s", script) + } + if !strings.Contains(script, `exit "$probe_rc"`) { + t.Fatalf("unreachable broker must stop bootstrap before clone: %s", script) + } +} + func TestBuildContainerConfig_ImageDefaultsToRuntime(t *testing.T) { cm := newCMTest(t) cfg := ContainerConfig{ diff --git a/apps/backend/internal/agent/runtime/lifecycle/credential_uploader.go b/apps/backend/internal/agent/runtime/lifecycle/credential_uploader.go index 2372176fc5..8156aacee2 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/credential_uploader.go +++ b/apps/backend/internal/agent/runtime/lifecycle/credential_uploader.go @@ -4,16 +4,12 @@ import ( "context" "fmt" "os" - "os/exec" "path/filepath" - "strings" - "time" "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/remoteauth" "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/internal/common/subproc" ) // FileUploader abstracts writing files to a remote environment. Used by @@ -69,31 +65,3 @@ func UploadCredentialFiles( return nil } - -// DetectGHToken runs `gh auth token` locally and returns the GitHub OAuth token. -// -// Splits the throttle wait (30s) from the exec budget (5s) so a busy gh pool -// can't silently disable token injection. Critically the exec context is -// built AFTER Acquire returns — otherwise its 5s deadline would already be -// ticking down while we waited in the throttle queue. -func DetectGHToken() (string, error) { - acquireCtx, cancelAcquire := context.WithTimeout(context.Background(), 30*time.Second) - defer cancelAcquire() - release, err := subproc.GH().Acquire(acquireCtx) - if err != nil { - return "", fmt.Errorf("gh throttle acquire: %w", err) - } - defer release() - execCtx, cancelExec := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelExec() - cmd := exec.CommandContext(execCtx, "gh", "auth", "token") - out, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("gh auth token failed: %w", err) - } - token := strings.TrimSpace(string(out)) - if token == "" { - return "", fmt.Errorf("gh auth token returned empty") - } - return token, nil -} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go index 0dc4e7f16c..f9b58e7801 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go @@ -79,7 +79,8 @@ type DockerExecutor struct { // newClientFunc creates the Docker client. Defaults to docker.NewClient. // Override in tests to simulate failures. - newClientFunc func(config.DockerConfig, *logger.Logger) (*docker.Client, error) + newClientFunc func(config.DockerConfig, *logger.Logger) (*docker.Client, error) + brokerPreflight func(context.Context, brokerAgentctlProcessClient, string, map[string]string) error // Lazy-initialized on first use via ensureClient(). // Uses mu + initialized instead of sync.Once so that transient Docker @@ -98,10 +99,11 @@ type DockerExecutor struct { // for host home bind mounts that were leaking host state into containers). func NewDockerExecutor(cfg config.DockerConfig, kandevHomeDir string, log *logger.Logger) *DockerExecutor { return &DockerExecutor{ - cfg: cfg, - kandevHomeDir: kandevHomeDir, - logger: log.WithFields(zap.String("runtime", "docker")), - newClientFunc: docker.NewClient, + cfg: cfg, + kandevHomeDir: kandevHomeDir, + logger: log.WithFields(zap.String("runtime", "docker")), + newClientFunc: docker.NewClient, + brokerPreflight: runBrokerReachabilityViaAgentctl, } } @@ -379,6 +381,12 @@ type reconnectAgentctlConn struct { reusingProcess bool } +type reconnectControlClient interface { + GetInstance(context.Context, string) (*agentctl.InstanceInfo, error) + DeleteInstance(context.Context, string) error + CreateInstance(context.Context, *agentctl.CreateInstanceRequest) (*agentctl.CreateInstanceResponse, error) +} + // bringupAgentctl health-checks agentctl on the container, finds (or creates) // the agent instance, transparently re-handshakes on a 401, and returns the // resolved instance endpoint for the user-facing client. @@ -457,8 +465,8 @@ func isAgentctlAuthError(err error) bool { // Returns the instance port and whether the agent subprocess is also running. func (r *DockerExecutor) findExistingInstance( ctx context.Context, - dockerClient *docker.Client, - ctl *agentctl.ControlClient, + dockerClient hostPortLookup, + ctl reconnectControlClient, req *ExecutorCreateRequest, containerID string, containerIP string, @@ -468,6 +476,20 @@ func (r *DockerExecutor) findExistingInstance( // Try to get the existing instance by its ID instance, err := ctl.GetInstance(ctx, prevExecutionID) if err == nil && instance != nil && instance.Port > 0 { + if hasManagedGitHubBrokerEnv(req.Env) { + instanceHost, instancePort := resolveDockerEndpoint( + ctx, dockerClient, containerID, instance.Port, containerIP, r.logger) + client := agentctl.NewClient(instanceHost, instancePort, r.logger, + agentctl.WithAuthToken(authToken)) + defer client.Close() + if preflightErr := r.brokerPreflight(ctx, client, req.SessionID, req.Env); preflightErr != nil { + return 0, false, preflightErr + } + if deleteErr := ctl.DeleteInstance(ctx, prevExecutionID); deleteErr != nil { + return 0, false, fmt.Errorf("delete stale broker-backed instance: %w", deleteErr) + } + return createReconnectInstance(ctx, ctl, req, prevExecutionID) + } // Instance exists, check if agent subprocess is running instanceHost, instancePort := resolveDockerEndpoint(ctx, dockerClient, containerID, instance.Port, containerIP, r.logger) client := agentctl.NewClient(instanceHost, instancePort, r.logger, @@ -481,7 +503,16 @@ func (r *DockerExecutor) findExistingInstance( } // Instance not found — create a new instance in the existing container - createReq := buildReconnectCreateInstanceRequest(req, prevExecutionID) + return createReconnectInstance(ctx, ctl, req, prevExecutionID) +} + +func createReconnectInstance( + ctx context.Context, + ctl reconnectControlClient, + req *ExecutorCreateRequest, + instanceID string, +) (int, bool, error) { + createReq := buildReconnectCreateInstanceRequest(req, instanceID) resp, createErr := ctl.CreateInstance(ctx, createReq) if createErr != nil { return 0, false, fmt.Errorf("failed to create new instance: %w", createErr) @@ -510,7 +541,7 @@ func buildReconnectCreateInstanceRequest(req *ExecutorCreateRequest, instanceID ID: instanceID, WorkspacePath: dockerWorkspacePath, AgentType: agentType, - Env: req.Env, + Env: cloneStringMap(req.Env), AutoApprovePermissions: autoApprovePermissionsOverride( req.AutoApprovePermissions, req.AutoApprovePermissionsOverride, diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go index 69e2b04e54..d991e42a05 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go @@ -2,6 +2,7 @@ package lifecycle import ( "context" + "errors" "fmt" "net/http" "net/http/httptest" @@ -16,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/agent/docker" "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/agent/runtime/activity" + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" ) @@ -709,6 +711,129 @@ func TestBuildReconnectCreateInstanceRequestOmitsAutoApproveOverrideWhenUnset(t } } +type recordingReconnectControl struct { + methods []string + created *agentctl.CreateInstanceRequest +} + +func (c *recordingReconnectControl) GetInstance( + _ context.Context, + _ string, +) (*agentctl.InstanceInfo, error) { + c.methods = append(c.methods, "GET") + return &agentctl.InstanceInfo{ID: "instance-1", Port: 41001}, nil +} + +func (c *recordingReconnectControl) DeleteInstance(_ context.Context, _ string) error { + c.methods = append(c.methods, "DELETE") + return nil +} + +func (c *recordingReconnectControl) CreateInstance( + _ context.Context, + req *agentctl.CreateInstanceRequest, +) (*agentctl.CreateInstanceResponse, error) { + c.methods = append(c.methods, "POST") + c.created = req + return &agentctl.CreateInstanceResponse{ID: "instance-1", Port: 41002}, nil +} + +func TestDockerManagedBrokerReconnectRecreatesInstanceWithFreshLease(t *testing.T) { + const freshLease = "fresh-lease-after-restart" + control := &recordingReconnectControl{} + req := &ExecutorCreateRequest{ + InstanceID: "instance-1", + TaskID: "task-1", + SessionID: "session-1", + Env: map[string]string{ + envKeyGitHubCredentialBrokerURL: "https://kandev.example/api/v1/github/credentials/resolve", + envKeyGitHubCredentialLease: freshLease, + }, + } + dockerExec := NewDockerExecutor(config.DockerConfig{}, "", newTestDockerLogger()) + dockerExec.brokerPreflight = func( + context.Context, + brokerAgentctlProcessClient, + string, + map[string]string, + ) error { + return nil + } + gotPort, reused, err := dockerExec.findExistingInstance( + context.Background(), stubHostPortLookup{host: "127.0.0.1", port: 1}, control, + req, "container-1", "172.17.0.2", "instance-1", "", + ) + if err != nil { + t.Fatalf("findExistingInstance() error = %v", err) + } + if reused { + t.Fatal("managed broker reconnect reused the stale instance") + } + if gotPort != 41002 { + t.Fatalf("instance port = %d, want recreated port 41002", gotPort) + } + if got, want := strings.Join(control.methods, ","), "GET,DELETE,POST"; got != want { + t.Fatalf("control methods = %s, want %s", got, want) + } + if control.created == nil || control.created.Env[envKeyGitHubCredentialLease] != freshLease { + t.Fatalf("recreated request = %#v, want lease %q", control.created, freshLease) + } +} + +func TestDockerManagedBrokerReconnectStopsBeforeReplacementWhenUnreachable(t *testing.T) { + control := &recordingReconnectControl{} + req := &ExecutorCreateRequest{ + InstanceID: "instance-1", + SessionID: "session-1", + Env: map[string]string{ + envKeyGitHubCredentialBrokerURL: "https://unreachable.example/resolve", + envKeyGitHubCredentialLease: "fresh-lease", + }, + } + dockerExec := NewDockerExecutor(config.DockerConfig{}, "", newTestDockerLogger()) + dockerExec.brokerPreflight = func( + context.Context, + brokerAgentctlProcessClient, + string, + map[string]string, + ) error { + return ErrGitHubCredentialBrokerUnreachable + } + _, _, err := dockerExec.findExistingInstance( + context.Background(), stubHostPortLookup{host: "127.0.0.1", port: 1}, control, + req, "container-1", "172.17.0.2", "instance-1", "", + ) + if !errors.Is(err, ErrGitHubCredentialBrokerUnreachable) { + t.Fatalf("findExistingInstance() error = %v, want unreachable", err) + } + if got, want := strings.Join(control.methods, ","), "GET"; got != want { + t.Fatalf("control methods = %s, want %s", got, want) + } +} + +func TestDockerExplicitTokenReconnectDoesNotReplaceInstance(t *testing.T) { + control := &recordingReconnectControl{} + req := &ExecutorCreateRequest{ + InstanceID: "instance-1", + SessionID: "session-1", + Env: map[string]string{"GITHUB_TOKEN": "explicit-profile-token"}, + } + dockerExec := NewDockerExecutor(config.DockerConfig{}, "", newTestDockerLogger()) + port, _, err := dockerExec.findExistingInstance( + context.Background(), stubHostPortLookup{host: "127.0.0.1", port: 1}, control, + req, "container-1", "172.17.0.2", "instance-1", "", + ) + if err != nil { + t.Fatalf("findExistingInstance() error = %v", err) + } + if port != 41001 { + t.Fatalf("instance port = %d, want existing port", port) + } + if got, want := strings.Join(control.methods, ","), "GET"; got != want { + t.Fatalf("control methods = %s, want %s", got, want) + } +} + func TestResolvePrepareScript(t *testing.T) { log := newTestDockerLogger() exec := NewDockerExecutor(config.DockerConfig{}, "", log) diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go index 94e44ccccb..56b02453b4 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go @@ -162,6 +162,10 @@ func (r *SpritesExecutor) CreateInstance(ctx context.Context, req *ExecutorCreat return nil, err } } + if err := r.preflightGitHubCredentialBroker(ctx, sprite, req); err != nil { + r.cleanupOnFailure(ctx, sprite, req.InstanceID, destroyOnFailure) + return nil, err + } // Steps 1-3: Upload agentctl, credentials, prepare script if err := r.stepSetupEnvironment(ctx, sprite, req, reconnect, report); err != nil { @@ -203,6 +207,24 @@ func (r *SpritesExecutor) CreateInstance(ctx context.Context, req *ExecutorCreat return r.buildInstanceResult(req, spriteName, sprite, localPort, instancePort, reusingExisting), nil } +func (r *SpritesExecutor) preflightGitHubCredentialBroker( + ctx context.Context, + sprite *sprites.Sprite, + req *ExecutorCreateRequest, +) error { + return runBrokerReachabilityPreflight(ctx, req.Env, func( + ctx context.Context, + command string, + env map[string]string, + ) ([]byte, error) { + stepCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + cmd := sprite.CommandContext(stepCtx, "sh", "-c", command) + cmd.Env = r.buildSpriteEnv(env) + return cmd.CombinedOutput() + }) +} + // resolveSpriteName determines the sprite name based on request and reconnect state. func (r *SpritesExecutor) resolveSpriteName(req *ExecutorCreateRequest, reconnect bool) string { if !reconnect { @@ -418,14 +440,28 @@ func (r *SpritesExecutor) stepEnsureAgentInstance( if reconnect { instanceID := spritesAgentInstanceID(req) port, portErr := r.getExistingInstancePort(ctx, sprite, instanceID) - if portErr == nil && port > 0 && r.isAgentSubprocessRunning(ctx, sprite, port) { - instancePort = port - reusingExisting = true - } else if portErr == nil && port > 0 { - instancePort = port - r.logger.Info("existing instance found but agent subprocess not running", - zap.String("instance_id", instanceID), - zap.Int("port", port)) + if portErr == nil && port > 0 { + switch { + case hasManagedGitHubBrokerEnv(req.Env): + replacementPort, err := replaceSpriteReconnectInstance(ctx, + liveSpriteReconnectInstanceControl{executor: r, sprite: sprite}, req, instanceID) + if err != nil { + completeStepError(&step, err.Error()) + report(spriteStepAgentInstance, step) + return 0, false, err + } + instancePort = replacementPort + r.logger.Info("deleted existing agent instance to refresh managed GitHub credentials", + zap.String("instance_id", instanceID)) + case r.isAgentSubprocessRunning(ctx, sprite, port): + instancePort = port + reusingExisting = true + default: + instancePort = port + r.logger.Info("existing instance found but agent subprocess not running", + zap.String("instance_id", instanceID), + zap.Int("port", port)) + } } } if instancePort == 0 { diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_clone_auth_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_clone_auth_test.go index d52f9ed1e5..c815730bcb 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_clone_auth_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_clone_auth_test.go @@ -1,12 +1,33 @@ package lifecycle import ( + "context" "errors" + "strings" "testing" "github.com/stretchr/testify/require" ) +type recordingSpriteReconnectControl struct { + calls []string + created *ExecutorCreateRequest +} + +func (c *recordingSpriteReconnectControl) Delete(_ context.Context, _ string) error { + c.calls = append(c.calls, "DELETE") + return nil +} + +func (c *recordingSpriteReconnectControl) Create( + _ context.Context, + req *ExecutorCreateRequest, +) (int, error) { + c.calls = append(c.calls, "POST") + c.created = req + return 41002, nil +} + func TestRewriteGitHubSSHToHTTPS(t *testing.T) { tests := []struct { name string @@ -78,3 +99,59 @@ func TestIsTransientUploadError(t *testing.T) { require.False(t, isTransientUploadError(errors.New("permission denied"))) require.False(t, isTransientUploadError(errors.New("upload failed: HTTP 400"))) } + +func TestSpriteCreateInstanceRequestCarriesRefreshedEnvironment(t *testing.T) { + req := &ExecutorCreateRequest{ + InstanceID: "instance-1", + TaskID: "task-1", + SessionID: "session-1", + Env: map[string]string{ + envKeyGitHubCredentialBrokerURL: "https://kandev.example/api/v1/github/credentials/resolve", + envKeyGitHubCredentialLease: "new-lease-after-restart", + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "credential.https://github.com.helper", + "GIT_CONFIG_VALUE_0": "!agentctl git-credential", + "GITHUB_TOKEN": "explicit-profile-token", + }, + } + + got := spriteCreateInstanceRequest(req) + if got.Env[envKeyGitHubCredentialLease] != "new-lease-after-restart" { + t.Fatalf("lease = %q, want refreshed lease", got.Env[envKeyGitHubCredentialLease]) + } + if got.Env["GIT_CONFIG_VALUE_0"] != "!agentctl git-credential" { + t.Fatalf("git config = %q", got.Env["GIT_CONFIG_VALUE_0"]) + } + if got.Env["GITHUB_TOKEN"] != "explicit-profile-token" { + t.Fatalf("explicit profile token = %q", got.Env["GITHUB_TOKEN"]) + } + req.Env[envKeyGitHubCredentialLease] = "mutated" + if got.Env[envKeyGitHubCredentialLease] != "new-lease-after-restart" { + t.Fatal("CreateInstanceRequest env aliases the mutable launch request") + } +} + +func TestSpritesBrokerReconnectRequiresCredentialRefresh(t *testing.T) { + managed := map[string]string{ + envKeyGitHubCredentialBrokerURL: "https://kandev.example/api/v1/github/credentials/resolve", + envKeyGitHubCredentialLease: "new-lease", + } + if !hasManagedGitHubBrokerEnv(managed) { + t.Fatal("managed broker reconnect must refresh the running subprocess") + } + control := &recordingSpriteReconnectControl{} + req := &ExecutorCreateRequest{Env: managed} + port, err := replaceSpriteReconnectInstance(context.Background(), control, req, "instance-1") + if err != nil { + t.Fatalf("replaceSpriteReconnectInstance() error = %v", err) + } + if port != 41002 || strings.Join(control.calls, ",") != "DELETE,POST" { + t.Fatalf("replacement result: port=%d calls=%v", port, control.calls) + } + if control.created.Env[envKeyGitHubCredentialLease] != "new-lease" { + t.Fatalf("replacement lease = %q", control.created.Env[envKeyGitHubCredentialLease]) + } + if hasManagedGitHubBrokerEnv(map[string]string{"GITHUB_TOKEN": "explicit-profile-token"}) { + t.Fatal("explicit profile token reconnect must preserve existing behavior") + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials.go index 11a02bdc6b..4a19ffd47c 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials.go @@ -14,8 +14,8 @@ import ( ) // uploadCredentials reads the remote_credentials metadata and uploads the selected -// credential files to the sprite. Also handles gh_cli_token auto-detect, -// secret-based auth via remote_auth_secrets, and agent auth setup scripts. +// credential files to the sprite. It also handles secret-based auth via +// remote_auth_secrets and agent auth setup scripts. func (r *SpritesExecutor) uploadCredentials( ctx context.Context, sprite *sprites.Sprite, @@ -40,7 +40,8 @@ func (r *SpritesExecutor) uploadCredentials( return fmt.Errorf("failed to parse remote_credentials: %w", err) } - // Handle gh_cli_token: detect locally and inject as env var + // Ignore the retired host-global gh token method in stale profiles. GitHub + // automation credentials are supplied by the workspace credential broker. selectedMethodIDs = r.resolveGHToken(selectedMethodIDs, req) if len(selectedMethodIDs) == 0 { @@ -119,22 +120,10 @@ func (r *SpritesExecutor) runAuthSetupScripts( } } -// resolveGHToken handles the gh_cli_token credential: detects the token locally -// and injects it as GITHUB_TOKEN in the request env. Returns filtered credential IDs. +// resolveGHToken filters the retired host-global gh token method from stale +// profiles. Explicit secret-backed GITHUB_TOKEN values are resolved separately. func (r *SpritesExecutor) resolveGHToken(credentialIDs []string, req *ExecutorCreateRequest) []string { - if !containsID(credentialIDs, "gh_cli_token") { - return credentialIDs - } - token, err := DetectGHToken() - if err != nil { - r.logger.Warn("failed to detect gh token", zap.Error(err)) - } else { - if req.Env == nil { - req.Env = make(map[string]string) - } - req.Env["GITHUB_TOKEN"] = token - r.logger.Debug("set GITHUB_TOKEN from local gh auth token") - } + _ = req return removeID(credentialIDs, "gh_cli_token") } @@ -216,15 +205,6 @@ func (r *SpritesExecutor) resolveRemoteAuthHomeDir( return home, nil } -func containsID(ids []string, target string) bool { - for _, id := range ids { - if id == target { - return true - } - } - return false -} - func removeID(ids []string, target string) []string { result := make([]string, 0, len(ids)) for _, id := range ids { diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_e2e_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_e2e_test.go index b7bacb9389..63f4f74855 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_e2e_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_e2e_test.go @@ -133,9 +133,9 @@ func TestSpritesE2E_FullFlow(t *testing.T) { require.Contains(t, content.Content, "github.com/gin-gonic/gin") t.Logf("go.mod: %d bytes", len(content.Content)) - _, err = client.CreateFile(ctx, "e2e-test.txt") + _, err = client.CreateFile(ctx, "e2e-test.txt", "") require.NoError(t, err) - _, err = client.DeleteFile(ctx, "e2e-test.txt") + _, err = client.DeleteFile(ctx, "e2e-test.txt", "") require.NoError(t, err) t.Log("file create+delete roundtrip OK") }) @@ -317,7 +317,7 @@ func TestSpritesE2E_FullFlow(t *testing.T) { time.Sleep(2 * time.Second) // let config complete // Create a file, stage, commit via git API - _, err = client.CreateFile(ctx, "e2e-change.txt") + _, err = client.CreateFile(ctx, "e2e-change.txt", "") require.NoError(t, err) result, err := client.GitStage(ctx, []string{"e2e-change.txt"}, "") @@ -408,8 +408,9 @@ func TestSpritesE2E_FullFlow(t *testing.T) { } // TestSpritesE2E_CredentialsAndAuth verifies credential copying and agent auth on a Sprites sandbox. -// It copies all locally available credentials (SSH, gh, auggie, codex, gemini) to the sprite, -// then runs subtests for gh auth, SSH git clone, and one-off agent invocations. +// It copies all locally available agent credentials to the sprite, then runs +// subtests for SSH git clone and one-off agent invocations. Workspace GitHub +// authentication is supplied by the task-scoped broker, not host CLI state. func TestSpritesE2E_CredentialsAndAuth(t *testing.T) { token := os.Getenv("SPRITES_API_TOKEN") if token == "" { @@ -430,8 +431,7 @@ func TestSpritesE2E_CredentialsAndAuth(t *testing.T) { agentRegistry := registry.NewRegistry(log) agentRegistry.LoadDefaults() catalog := remoteauth.BuildCatalog(agentRegistry.ListEnabled()) - credIDs := make([]string, 0, len(catalog.Specs)+1) - credIDs = append(credIDs, "gh_cli_token") // auto-detect GH CLI token + credIDs := make([]string, 0, len(catalog.Specs)) for _, spec := range catalog.Specs { for _, method := range spec.Methods { if method.Type != "files" || !method.HasLocalFiles { @@ -516,28 +516,6 @@ func TestSpritesE2E_CredentialsAndAuth(t *testing.T) { require.Equal(t, gitUserEmail, strings.TrimSpace(res.Output), "sprite git user.email mismatch") }) - t.Run("GhCliAuth", func(t *testing.T) { - if !hasCredential(credIDs, "gh_cli_token") { - t.Skip("gh_cli_token not selected") - } - // GITHUB_TOKEN is injected via gh_cli_token auto-detect — verify gh can use it. - // gh auth status makes a network call that can be slow on sprites; retry with short timeout. - var res spriteCmdResult - for attempt := 1; attempt <= 2; attempt++ { - res = runSpriteCmdNoFail(t, client, localPort, log, "gh auth status 2>&1", 10*time.Second) - if res.Output != "" { - break - } - t.Logf("gh auth status attempt %d timed out, retrying...", attempt) - } - t.Logf("gh auth status output:\n%s", res.Output) - require.NotEmpty(t, res.Output, "gh auth status should produce output") - lower := strings.ToLower(res.Output) - require.True(t, - strings.Contains(lower, "logged in") || strings.Contains(lower, "github_token"), - "gh auth status should indicate logged in or GITHUB_TOKEN active, got: %s", res.Output) - }) - t.Run("AuggieAuth", func(t *testing.T) { if !hasCredentialPrefix(credIDs, "agent:auggie:files:") { t.Skip("auggie file auth credential not available on host") @@ -706,86 +684,6 @@ func runSpriteCmd( } } -// runSpriteCmdNoFail is like runSpriteCmd but returns an empty result on timeout instead of failing. -func runSpriteCmdNoFail( - t *testing.T, - client *agentctl.Client, - localPort int, - log *logger.Logger, - cmd string, - timeout time.Duration, -) spriteCmdResult { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - marker := "END_MARKER_" + randomSuffix() - - var output strings.Builder - var mu sync.Mutex - connected := make(chan struct{}) - - streamClient := agentctl.NewClient("127.0.0.1", localPort, log) - defer streamClient.Close() - - stream, err := streamClient.StreamWorkspace(ctx, agentctl.WorkspaceStreamCallbacks{ - OnConnected: func() { close(connected) }, - OnProcessOutput: func(o *agentctl.ProcessOutput) { - mu.Lock() - output.WriteString(o.Data) - mu.Unlock() - }, - }) - if err != nil { - return spriteCmdResult{ExitCode: -1} - } - defer stream.Close() - - select { - case <-connected: - case <-ctx.Done(): - return spriteCmdResult{ExitCode: -1} - } - - wrappedCmd := fmt.Sprintf("(%s); _ec=$?; echo \"%s EXIT_CODE=$_ec\"", cmd, marker) - if _, err = client.StartProcess(ctx, agentctl.StartProcessRequest{ - SessionID: "e2e-creds-session", - Command: wrappedCmd, - }); err != nil { - return spriteCmdResult{ExitCode: -1} - } - - // Wait for marker — return empty on timeout - deadline := time.After(timeout) - for { - select { - case <-deadline: - return spriteCmdResult{ExitCode: -1} - case <-time.After(500 * time.Millisecond): - mu.Lock() - if strings.Contains(output.String(), marker) { - fullOutput := output.String() - mu.Unlock() - - exitCode := -1 - idx := strings.Index(fullOutput, marker) - if idx >= 0 { - markerLine := fullOutput[idx:] - if ecIdx := strings.Index(markerLine, "EXIT_CODE="); ecIdx >= 0 { - ecStr := strings.TrimSpace(markerLine[ecIdx+len("EXIT_CODE="):]) - if n, parseErr := strconv.Atoi(strings.Split(ecStr, "\n")[0]); parseErr == nil { - exitCode = n - } - } - return spriteCmdResult{Output: strings.TrimSpace(fullOutput[:idx]), ExitCode: exitCode} - } - return spriteCmdResult{Output: strings.TrimSpace(fullOutput), ExitCode: exitCode} - } - mu.Unlock() - } - } -} - func hasCredentialPrefix(credIDs []string, prefix string) bool { for _, c := range credIDs { if strings.HasPrefix(c, prefix) { @@ -795,17 +693,6 @@ func hasCredentialPrefix(credIDs []string, prefix string) bool { return false } -// hasCredential checks for an exact credential ID match. -// Kept for compatibility with optional e2e subtests that may be toggled on/off. -func hasCredential(credIDs []string, target string) bool { - for _, c := range credIDs { - if c == target { - return true - } - } - return false -} - func hasAgentFileCredential(credIDs []string, agentID string) bool { return hasCredentialPrefix(credIDs, "agent:"+agentID+":files:") } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go index 9ea0a6593a..1d4073d3bc 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "net/url" "os" "path" "regexp" @@ -388,23 +389,7 @@ func (r *SpritesExecutor) createAgentInstance( sprite *sprites.Sprite, req *ExecutorCreateRequest, ) (int, error) { - instanceReq := agentctl.CreateInstanceRequest{ - ID: req.InstanceID, - WorkspacePath: spritesWorkspacePath, - SessionID: req.SessionID, - TaskID: req.TaskID, - Protocol: req.Protocol, - AgentType: agentTypeFromReq(req), - AutoApprovePermissions: autoApprovePermissionsOverride( - req.AutoApprovePermissions, - req.AutoApprovePermissionsOverride, - ), - McpServers: req.McpServers, - McpMode: req.McpMode, - RequiresProcessKill: requiresProcessKillFromReq(req), - StripEnv: stripEnvFromReq(req), - BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), - } + instanceReq := spriteCreateInstanceRequest(req) reqJSON, err := json.Marshal(instanceReq) if err != nil { return 0, fmt.Errorf("failed to marshal instance request: %w", err) @@ -434,6 +419,73 @@ func (r *SpritesExecutor) createAgentInstance( return resp.Port, nil } +func spriteCreateInstanceRequest(req *ExecutorCreateRequest) agentctl.CreateInstanceRequest { + return agentctl.CreateInstanceRequest{ + ID: req.InstanceID, + WorkspacePath: spritesWorkspacePath, + SessionID: req.SessionID, + TaskID: req.TaskID, + Protocol: req.Protocol, + AgentType: agentTypeFromReq(req), + AutoApprovePermissions: autoApprovePermissionsOverride( + req.AutoApprovePermissions, + req.AutoApprovePermissionsOverride, + ), + McpServers: req.McpServers, + McpMode: req.McpMode, + RequiresProcessKill: requiresProcessKillFromReq(req), + StripEnv: stripEnvFromReq(req), + BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), + Env: cloneStringMap(req.Env), + } +} + +type spriteReconnectInstanceControl interface { + Delete(context.Context, string) error + Create(context.Context, *ExecutorCreateRequest) (int, error) +} + +type liveSpriteReconnectInstanceControl struct { + executor *SpritesExecutor + sprite *sprites.Sprite +} + +func (c liveSpriteReconnectInstanceControl) Delete(ctx context.Context, instanceID string) error { + return c.executor.deleteAgentInstance(ctx, c.sprite, instanceID) +} + +func (c liveSpriteReconnectInstanceControl) Create(ctx context.Context, req *ExecutorCreateRequest) (int, error) { + return c.executor.createAgentInstance(ctx, c.sprite, req) +} + +func replaceSpriteReconnectInstance( + ctx context.Context, + control spriteReconnectInstanceControl, + req *ExecutorCreateRequest, + instanceID string, +) (int, error) { + if err := control.Delete(ctx, instanceID); err != nil { + return 0, err + } + return control.Create(ctx, req) +} + +func (r *SpritesExecutor) deleteAgentInstance( + ctx context.Context, + sprite *sprites.Sprite, + instanceID string, +) error { + stepCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + cmd := sprite.CommandContext(stepCtx, "sh", "-c", + fmt.Sprintf("curl -sf -X DELETE http://localhost:%d/api/v1/instances/%s", + r.agentctlPort, url.PathEscape(instanceID))) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("delete agent instance for GitHub credential refresh: %w (output: %s)", err, string(out)) + } + return nil +} + // getExistingInstancePort queries agentctl inside the sprite to check if a // previously created instance is still running. Returns its port if found. func (r *SpritesExecutor) getExistingInstancePort( diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go index 4118185c92..c9bf0ea9bf 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go @@ -59,6 +59,8 @@ type SSHExecutor struct { secretStore secrets.SecretStore agentList RemoteAuthAgentLister logger *logger.Logger + brokerPreflight func(context.Context, *ssh.Client, *ExecutorCreateRequest, SSHRemotePlatform) error + stopRemote func(context.Context, *ssh.Client, string, int) error mu sync.Mutex sessions map[string]*sshSessionState // keyed by ExecutorInstance.InstanceID @@ -72,13 +74,16 @@ func NewSSHExecutor( resolver *AgentctlResolver, log *logger.Logger, ) *SSHExecutor { - return &SSHExecutor{ + executor := &SSHExecutor{ agentctlResolver: resolver, secretStore: secretStore, agentList: agentList, logger: log.WithFields(zap.String("runtime", "ssh")), sessions: make(map[string]*sshSessionState), } + executor.brokerPreflight = executor.preflightGitHubCredentialBroker + executor.stopRemote = stopRemoteAgentctl + return executor } func (r *SSHExecutor) Name() executor.Name { return executor.NameSSH } @@ -168,9 +173,7 @@ func (r *SSHExecutor) workdirRoot(md map[string]interface{}) string { // backend restart), reuse the resumed SSH client + forwarder + remote pid // instead of starting a second remote agentctl on top of the live one. func (r *SSHExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) { - r.mu.Lock() - resumed, ok := r.sessions[req.InstanceID] - r.mu.Unlock() + resumed, ok := r.resumedStateForCreate(req) if ok { return r.buildResumedInstance(req, resumed), nil } @@ -190,6 +193,9 @@ func (r *SSHExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateReq } }() r.report(req.OnProgress, "Connecting to SSH host", PrepareStepCompleted, "") + if err := r.preflightGitHubCredentialBroker(ctx, client, req, SSHRemotePlatform{}); err != nil { + return nil, err + } agentctlBin, platform, err := r.prepareRemoteHost(ctx, client, req) if err != nil { @@ -227,6 +233,39 @@ func (r *SSHExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateReq return r.buildInstance(req, target, fwd, taskDir, sessionDir, port, pid, workdir, authToken), nil } +func (r *SSHExecutor) resumedStateForCreate(req *ExecutorCreateRequest) (*sshSessionState, bool) { + if req == nil || hasManagedGitHubBrokerEnv(req.Env) { + return nil, false + } + r.mu.Lock() + defer r.mu.Unlock() + state, ok := r.sessions[req.InstanceID] + return state, ok +} + +func (r *SSHExecutor) preflightGitHubCredentialBroker( + ctx context.Context, + client *ssh.Client, + req *ExecutorCreateRequest, + platform SSHRemotePlatform, +) error { + shell := sshShellForRemote(req.Metadata, platform) + return runBrokerReachabilityPreflight(ctx, req.Env, func( + ctx context.Context, + command string, + env map[string]string, + ) ([]byte, error) { + envScript, err := buildSSHEnvInitScript(env) + if err != nil { + return nil, err + } + input := strings.NewReader(envScript) + wrapped := WrapLoginShell(shell, "set -a; . /dev/stdin; set +a\n"+command) + stdout, stderr, err := runSSHCommandStdin(ctx, client, wrapped, input) + return []byte(stdout + stderr), err + }) +} + // prepareRemoteHost runs the steps that are independent of any particular // task: detect remote OS/arch and ensure the agentctl binary is on the host. // Returns the resolved remote agentctl path. @@ -294,7 +333,10 @@ func (r *SSHExecutor) startAndForwardAgentctl( if err != nil { return 0, 0, nil, "", fmt.Errorf("ssh: generate bootstrap nonce: %w", err) } - env := sshAgentctlLaunchEnv(req.Env, nonce) + // Keep only the managed broker values in the long-lived remote process. + // sshAgentctlLaunchEnv adds the bootstrap credentials required for the + // authenticated control handshake without forwarding profile secrets. + env := sshAgentctlLaunchEnv(managedGitHubBrokerEnv(req.Env), nonce) shell := sshShellForRemote(req.Metadata, platform) controlPort, pid, err := startRemoteAgentctl(ctx, client, shell, agentctlBin, taskDir, sessionDir, env, r.logger) if err != nil { @@ -505,6 +547,9 @@ func (r *SSHExecutor) ResumeRemoteInstance(ctx context.Context, req *ExecutorCre if pidStr == "" || portStr == "" || sessionDir == "" || taskDir == "" { return nil // not a resume — proceed with normal create } + if hasManagedGitHubBrokerEnv(req.Env) { + return r.resetManagedBrokerResume(ctx, req, pidStr, sessionDir) + } if err := requireSSHAgentctlAuthToken(req.AuthToken); err != nil { return err } @@ -613,6 +658,90 @@ func requireSSHAgentctlAuthToken(token string) error { return nil } +func (r *SSHExecutor) resetManagedBrokerResume( + ctx context.Context, + req *ExecutorCreateRequest, + pidStr, sessionDir string, +) error { + brokerPreflight := r.brokerPreflight + if brokerPreflight == nil { + brokerPreflight = r.preflightGitHubCredentialBroker + } + stopRemote := r.stopRemote + if stopRemote == nil { + stopRemote = stopRemoteAgentctl + } + + r.mu.Lock() + state := r.sessions[req.InstanceID] + r.mu.Unlock() + if state != nil { + return r.resetTrackedManagedBrokerResume(ctx, req, state, brokerPreflight, stopRemote) + } + + target, err := r.targetFromMetadata(req.Metadata) + if err != nil { + return err + } + client, err := dialSSH(ctx, target) + if err != nil { + return fmt.Errorf("ssh resume: connect for broker refresh: %w", err) + } + defer func() { _ = client.Close() }() + if err := brokerPreflight(ctx, client, req, SSHRemotePlatform{}); err != nil { + return err + } + pid, _ := strconv.Atoi(pidStr) + if err := stopRemote(ctx, client, sessionDir, pid); err != nil { + return fmt.Errorf("ssh resume: stop stale broker-backed agentctl: %w", err) + } + clearSSHResumeRuntimeMetadata(req.Metadata) + return nil +} + +func (r *SSHExecutor) resetTrackedManagedBrokerResume( + ctx context.Context, + req *ExecutorCreateRequest, + state *sshSessionState, + brokerPreflight func(context.Context, *ssh.Client, *ExecutorCreateRequest, SSHRemotePlatform) error, + stopRemote func(context.Context, *ssh.Client, string, int) error, +) error { + if err := brokerPreflight(ctx, state.client, req, SSHRemotePlatform{}); err != nil { + return err + } + r.mu.Lock() + if r.sessions[req.InstanceID] == state { + delete(r.sessions, req.InstanceID) + } + r.mu.Unlock() + if state.forwarder != nil { + _ = state.forwarder.Close() + } + if err := stopRemote(ctx, state.client, state.remoteDir, state.pid); err != nil { + if state.client != nil { + _ = state.client.Close() + } + return fmt.Errorf("ssh resume: stop stale broker-backed agentctl: %w", err) + } + if state.client != nil { + _ = state.client.Close() + } + clearSSHResumeRuntimeMetadata(req.Metadata) + return nil +} + +func clearSSHResumeRuntimeMetadata(metadata map[string]interface{}) { + for _, key := range []string{ + MetadataKeySSHRemoteSessionDir, + MetadataKeySSHRemoteAgentctlPort, + MetadataKeySSHRemoteAgentctlPID, + MetadataKeySSHLocalForwardPort, + MetadataKeySSHRemoteAgentctlURL, + } { + delete(metadata, key) + } +} + // GetRemoteStatus reports the SSH host's current reachability for the UI // status badge. Best-effort and bounded by a short timeout. func (r *SSHExecutor) GetRemoteStatus(ctx context.Context, instance *ExecutorInstance) (*RemoteStatus, error) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go index 5216e7c375..2585dac7e8 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go @@ -1,12 +1,15 @@ package lifecycle import ( + "context" "net/http" "os" "strings" "testing" "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/common/logger" + "golang.org/x/crypto/ssh" ) func TestSSHControlRequestUsesBearerToken(t *testing.T) { @@ -405,10 +408,15 @@ func TestSSHRemoteAgentEnv(t *testing.T) { // req.Env credential keys are forwarded; non-credential keys (HOME/PATH) are not. req := &ExecutorCreateRequest{Env: map[string]string{ - "CLAUDE_CODE_OAUTH_TOKEN": tokenFromReq, - "HOME": nonCredentialHome, - "PATH": nonCredentialPath, - "OPENAI_API_KEY": openAIKey, + "CLAUDE_CODE_OAUTH_TOKEN": tokenFromReq, + "HOME": nonCredentialHome, + "PATH": nonCredentialPath, + "OPENAI_API_KEY": openAIKey, + envKeyGitHubCredentialBrokerURL: "https://kandev.example/api/v1/github/credentials/resolve", + envKeyGitHubCredentialLease: "opaque-lease", + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "credential.https://github.com.helper", + "GIT_CONFIG_VALUE_0": "!agentctl git-credential", }} got := sshRemoteAgentEnv(req) if got["CLAUDE_CODE_OAUTH_TOKEN"] != tokenFromReq { @@ -417,6 +425,9 @@ func TestSSHRemoteAgentEnv(t *testing.T) { if got["OPENAI_API_KEY"] != openAIKey { t.Fatalf("OPENAI_API_KEY = %q, want %q", got["OPENAI_API_KEY"], openAIKey) } + if got[envKeyGitHubCredentialLease] != "opaque-lease" || got["GIT_CONFIG_KEY_0"] == "" { + t.Fatalf("managed GitHub broker env was not forwarded: %#v", got) + } if _, ok := got["HOME"]; ok { t.Error("HOME must NOT be forwarded to the remote agent") } @@ -453,3 +464,63 @@ func TestSSHRemoteAgentEnvEmpty(t *testing.T) { t.Fatalf("expected nil for no credentials, got %v", got) } } + +func TestSSHManagedBrokerResumeForcesFreshAgentctlWithNewLease(t *testing.T) { + sshExec := NewSSHExecutor(nil, nil, nil, logger.Default()) + sshExec.sessions["instance-1"] = &sshSessionState{pid: 1234, remoteDir: "/remote/session"} + req := &ExecutorCreateRequest{ + InstanceID: "instance-1", + Env: map[string]string{ + envKeyGitHubCredentialBrokerURL: "https://kandev.example/api/v1/github/credentials/resolve", + envKeyGitHubCredentialLease: "fresh-lease-after-backend-restart", + }, + Metadata: map[string]interface{}{ + MetadataKeySSHHost: "remote.example", + MetadataKeySSHRemoteSessionDir: "/remote/session", + MetadataKeySSHRemoteAgentctlPort: "41001", + MetadataKeySSHRemoteAgentctlPID: "1234", + MetadataKeySSHLocalForwardPort: "51001", + MetadataKeySSHRemoteAgentctlURL: "http://127.0.0.1:41001", + }, + } + + var probedLease string + sshExec.brokerPreflight = func(_ context.Context, _ *ssh.Client, probeReq *ExecutorCreateRequest, _ SSHRemotePlatform) error { + probedLease = managedGitHubBrokerEnv(probeReq.Env)[envKeyGitHubCredentialLease] + return nil + } + var stoppedPID int + sshExec.stopRemote = func(_ context.Context, _ *ssh.Client, _ string, pid int) error { + stoppedPID = pid + return nil + } + if err := sshExec.resetManagedBrokerResume(context.Background(), req, "1234", "/remote/session"); err != nil { + t.Fatalf("resetManagedBrokerResume() error = %v", err) + } + if probedLease != "fresh-lease-after-backend-restart" { + t.Fatalf("preflight lease = %q, want fresh lease", probedLease) + } + if stoppedPID != 1234 { + t.Fatalf("stopped pid = %d, want 1234", stoppedPID) + } + if _, tracked := sshExec.sessions["instance-1"]; tracked { + t.Fatal("stale broker-backed SSH session remains tracked") + } + if _, reused := sshExec.resumedStateForCreate(req); reused { + t.Fatal("managed broker recovery reused agentctl carrying an invalidated lease") + } + for _, key := range []string{ + MetadataKeySSHRemoteSessionDir, + MetadataKeySSHRemoteAgentctlPort, + MetadataKeySSHRemoteAgentctlPID, + MetadataKeySSHLocalForwardPort, + MetadataKeySSHRemoteAgentctlURL, + } { + if _, found := req.Metadata[key]; found { + t.Fatalf("stale resume metadata %q was retained", key) + } + } + if req.Metadata[MetadataKeySSHHost] != "remote.example" { + t.Fatal("connection metadata required for fresh SSH launch was removed") + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go index 0329ea1d8a..168c67ffb6 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go @@ -131,23 +131,10 @@ func (r *SSHExecutor) runOneAuthSetupScript( zap.String("method_id", method.MethodID)) } -// resolveGHToken handles the gh_cli_token credential: detects the token on -// the kandev host and injects it as GITHUB_TOKEN in req.Env. Returns the -// filtered method ID list (gh_cli_token removed). Same shape as Sprites. +// resolveGHToken filters the retired host-global gh token method from stale +// profiles. Explicit secret-backed GITHUB_TOKEN values are resolved separately. func (r *SSHExecutor) resolveGHToken(ids []string, req *ExecutorCreateRequest) []string { - if !containsID(ids, "gh_cli_token") { - return ids - } - token, err := DetectGHToken() - if err != nil { - r.logger.Warn("failed to detect gh token", zap.Error(err)) - } else { - if req.Env == nil { - req.Env = make(map[string]string) - } - req.Env["GITHUB_TOKEN"] = token - r.logger.Debug("set GITHUB_TOKEN from local gh auth token") - } + _ = req return removeID(ids, "gh_cli_token") } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go index 780b147d0a..d784c199bc 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go @@ -2,10 +2,64 @@ package lifecycle import ( "context" + "os" + "path/filepath" "strings" "testing" + + "github.com/kandev/kandev/internal/agent/remoteauth" ) +func TestRemoteExecutorsIgnoreLegacyHostGHTokenSelection(t *testing.T) { + binDir := t.TempDir() + ghPath := filepath.Join(binDir, "gh") + if err := os.WriteFile(ghPath, []byte("#!/bin/sh\nprintf host-global-token\n"), 0o755); err != nil { + t.Fatalf("write fake gh: %v", err) + } + t.Setenv("PATH", binDir) + + tests := []struct { + name string + resolve func([]string, *ExecutorCreateRequest) []string + }{ + {name: "ssh", resolve: (&SSHExecutor{logger: newTestLogger()}).resolveGHToken}, + {name: "sprites", resolve: (&SpritesExecutor{logger: newTestLogger()}).resolveGHToken}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &ExecutorCreateRequest{Env: map[string]string{}} + remaining := tt.resolve([]string{"gh_cli_token", "agent:codex:files:0"}, req) + if got := req.Env["GITHUB_TOKEN"]; got != "" { + t.Fatalf("GITHUB_TOKEN = %q, want no host-global token injection", got) + } + if len(remaining) != 1 || remaining[0] != "agent:codex:files:0" { + t.Fatalf("remaining methods = %v, want stale gh_cli_token filtered", remaining) + } + }) + } +} + +func TestRemoteExecutorsResolveExplicitGitHubTokenSecret(t *testing.T) { + store := &mockSecretStore{store: map[string]string{"secret-1": "workspace-token"}} + metadata := map[string]interface{}{"remote_auth_secrets": `{"gh_cli_env":"secret-1"}`} + tests := []struct { + name string + resolve func(context.Context, *ExecutorCreateRequest, remoteauth.Catalog) + }{ + {name: "ssh", resolve: (&SSHExecutor{secretStore: store, logger: newTestLogger()}).resolveAuthSecrets}, + {name: "sprites", resolve: (&SpritesExecutor{secretStore: store, logger: newTestLogger()}).resolveAuthSecrets}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &ExecutorCreateRequest{Metadata: metadata, Env: map[string]string{}} + tt.resolve(context.Background(), req, remoteauth.BuildCatalogForHost(nil, "linux", "")) + if got := req.Env["GITHUB_TOKEN"]; got != "workspace-token" { + t.Fatalf("GITHUB_TOKEN = %q, want selected stored secret", got) + } + }) + } +} + func TestWrapLoginShell(t *testing.T) { t.Run("empty shell defaults to bash", func(t *testing.T) { got := WrapLoginShell("", "echo hi") diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go index bfe2284427..53c1a455a4 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go @@ -723,6 +723,9 @@ func sshRemoteAgentEnv(req *ExecutorCreateRequest) map[string]string { env[key] = val } } + for key, value := range managedGitHubBrokerEnv(req.Env) { + env[key] = value + } if len(env) == 0 { return nil } diff --git a/apps/backend/internal/agent/runtime/lifecycle/remote_github_env.go b/apps/backend/internal/agent/runtime/lifecycle/remote_github_env.go new file mode 100644 index 0000000000..8758075f9e --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/remote_github_env.go @@ -0,0 +1,57 @@ +package lifecycle + +import ( + "strconv" + + "github.com/kandev/kandev/internal/githubauth" +) + +var managedGitHubBrokerEnvKeys = []string{ + githubauth.CredentialBrokerURLEnv, + githubauth.CredentialLeaseEnv, + githubauth.CredentialTaskIDEnv, + githubauth.CredentialSessionIDEnv, + githubauth.CredentialRepositoryEnv, + githubauth.CredentialOwnerEnv, + githubauth.CredentialRepoEnv, + githubauth.CredentialHostEnv, + githubauth.CredentialScopesEnv, + "GIT_TERMINAL_PROMPT", +} + +// managedGitHubBrokerEnv returns only the runtime values needed by the +// broker-backed git helper and gh shim. It deliberately excludes unrelated +// profile and control-plane secrets from the long-lived agentctl process. +func managedGitHubBrokerEnv(env map[string]string) map[string]string { + if env[githubauth.CredentialBrokerURLEnv] == "" { + return nil + } + result := make(map[string]string, len(managedGitHubBrokerEnvKeys)+1) + for _, key := range managedGitHubBrokerEnvKeys { + if value := env[key]; value != "" { + result[key] = value + } + } + copyIndexedGitConfig(env, result) + return result +} + +func copyIndexedGitConfig(source, target map[string]string) { + count, err := strconv.Atoi(source["GIT_CONFIG_COUNT"]) + if err != nil || count < 1 || count > 64 { + return + } + target["GIT_CONFIG_COUNT"] = strconv.Itoa(count) + for index := range count { + keyName := "GIT_CONFIG_KEY_" + strconv.Itoa(index) + valueName := "GIT_CONFIG_VALUE_" + strconv.Itoa(index) + if key := source[keyName]; key != "" { + target[keyName] = key + target[valueName] = source[valueName] + } + } +} + +func hasManagedGitHubBrokerEnv(env map[string]string) bool { + return env[githubauth.CredentialBrokerURLEnv] != "" && env[githubauth.CredentialLeaseEnv] != "" +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/remote_github_env_test.go b/apps/backend/internal/agent/runtime/lifecycle/remote_github_env_test.go new file mode 100644 index 0000000000..9ebee36338 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/remote_github_env_test.go @@ -0,0 +1,86 @@ +package lifecycle + +import ( + "strings" + "testing" + + "github.com/kandev/kandev/internal/githubauth" +) + +const ( + envKeyGitHubCredentialBrokerURL = githubauth.CredentialBrokerURLEnv + envKeyGitHubCredentialLease = githubauth.CredentialLeaseEnv + envKeyGitHubCredentialTaskID = githubauth.CredentialTaskIDEnv + envKeyGitHubCredentialSessionID = githubauth.CredentialSessionIDEnv + envKeyGitHubCredentialRepository = githubauth.CredentialRepositoryEnv + envKeyGitHubCredentialOwner = githubauth.CredentialOwnerEnv + envKeyGitHubCredentialRepo = githubauth.CredentialRepoEnv + envKeyGitHubCredentialHost = githubauth.CredentialHostEnv + envKeyGitHubCredentialScopes = githubauth.CredentialScopesEnv +) + +func TestManagedGitHubBrokerEnvUsesStrictAllowlist(t *testing.T) { + input := map[string]string{ + envKeyGitHubCredentialBrokerURL: "https://kandev.example/api/v1/github/credentials/resolve", + envKeyGitHubCredentialLease: "opaque-lease", + envKeyGitHubCredentialTaskID: "task-1", + envKeyGitHubCredentialSessionID: "session-1", + envKeyGitHubCredentialRepository: "repo-1", + envKeyGitHubCredentialOwner: "acme", + envKeyGitHubCredentialRepo: "widgets", + envKeyGitHubCredentialHost: "github.com", + envKeyGitHubCredentialScopes: `[{"lease":"opaque-lease"}]`, + "GIT_TERMINAL_PROMPT": "0", + "GIT_CONFIG_COUNT": "2", + "GIT_CONFIG_KEY_0": "credential.https://github.com.helper", + "GIT_CONFIG_VALUE_0": "!agentctl git-credential", + "GIT_CONFIG_KEY_1": "credential.useHttpPath", + "GIT_CONFIG_VALUE_1": "true", + "OPENAI_API_KEY": "must-not-reach-agentctl", + "GITHUB_TOKEN": "profile-token-must-not-reach-agentctl", + "HOME": "/control-plane/home", + "GIT_CONFIG_KEY_99": "credential.helper", + "GIT_CONFIG_VALUE_99": "!ambient-helper", + } + + got := managedGitHubBrokerEnv(input) + for _, key := range []string{ + envKeyGitHubCredentialBrokerURL, + envKeyGitHubCredentialLease, + envKeyGitHubCredentialScopes, + "GIT_TERMINAL_PROMPT", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", + "GIT_CONFIG_VALUE_0", + "GIT_CONFIG_KEY_1", + "GIT_CONFIG_VALUE_1", + } { + if got[key] != input[key] { + t.Errorf("%s = %q, want %q", key, got[key], input[key]) + } + } + for _, key := range []string{"OPENAI_API_KEY", "GITHUB_TOKEN", "HOME", "GIT_CONFIG_KEY_99"} { + if _, ok := got[key]; ok { + t.Errorf("unapproved key %s reached remote agentctl env", key) + } + } + launchInput, err := buildSSHEnvInitScript(got) + if err != nil { + t.Fatalf("buildSSHEnvInitScript: %v", err) + } + if strings.Contains(launchInput, "must-not-reach-agentctl") || + strings.Contains(launchInput, "profile-token-must-not-reach-agentctl") || + strings.Contains(launchInput, "ambient-helper") { + t.Fatalf("remote agentctl stdin leaked unapproved values: %s", launchInput) + } +} + +func TestManagedGitHubBrokerEnvDoesNotActivateForExplicitProfileToken(t *testing.T) { + env := map[string]string{"GITHUB_TOKEN": "explicit-profile-token"} + if got := managedGitHubBrokerEnv(env); got != nil { + t.Fatalf("managedGitHubBrokerEnv() = %#v, want nil", got) + } + if hasManagedGitHubBrokerEnv(env) { + t.Fatal("explicit profile token must not be treated as a managed broker lease") + } +} diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 0902288596..3e0423ccf1 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -16,6 +16,7 @@ import ( "encoding/hex" "fmt" "os" + "path/filepath" "strconv" "strings" "sync" @@ -511,6 +512,9 @@ func CollectAgentEnv(additional map[string]string) []string { for k, v := range additional { envMap[k] = v } + if envMap["KANDEV_GITHUB_CREDENTIAL_BROKER_URL"] != "" { + prependPathEntry(envMap, envMap["KANDEV_GITHUB_CLI_SHIM_DIR"]) + } // Convert back to slice result := make([]string, 0, len(envMap)) @@ -520,6 +524,22 @@ func CollectAgentEnv(additional map[string]string) []string { return result } +func prependPathEntry(env map[string]string, entry string) { + if entry == "" { + return + } + cleanEntry := filepath.Clean(entry) + parts := filepath.SplitList(env["PATH"]) + filtered := make([]string, 0, len(parts)+1) + filtered = append(filtered, entry) + for _, part := range parts { + if filepath.Clean(part) != cleanEntry { + filtered = append(filtered, part) + } + } + env["PATH"] = strings.Join(filtered, string(os.PathListSeparator)) +} + func envBool(env []string, key string) bool { prefix := key + "=" for _, entry := range env { diff --git a/apps/backend/internal/agentctl/server/config/config_test.go b/apps/backend/internal/agentctl/server/config/config_test.go index 177e76ee22..e3b581fa33 100644 --- a/apps/backend/internal/agentctl/server/config/config_test.go +++ b/apps/backend/internal/agentctl/server/config/config_test.go @@ -1,6 +1,30 @@ package config -import "testing" +import ( + "os" + "strings" + "testing" +) + +func TestCollectAgentEnvKeepsGitHubCLIShimAheadOfProfilePath(t *testing.T) { + t.Setenv("KANDEV_GITHUB_CREDENTIAL_BROKER_URL", "https://kandev.example/api/github/credentials/resolve") + t.Setenv("KANDEV_GITHUB_CLI_SHIM_DIR", "/kandev/shims") + env := CollectAgentEnv(map[string]string{"PATH": "/profile/bin:/usr/bin"}) + want := strings.Join([]string{"/kandev/shims", "/profile/bin", "/usr/bin"}, string(os.PathListSeparator)) + if got := envSliceValue(env, "PATH"); got != want { + t.Fatalf("PATH = %q, want %q", got, want) + } +} + +func envSliceValue(env []string, key string) string { + prefix := key + "=" + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + return strings.TrimPrefix(entry, prefix) + } + } + return "" +} func TestValidateCommandArgs(t *testing.T) { for _, tc := range []struct { diff --git a/apps/backend/internal/automation/evaluator.go b/apps/backend/internal/automation/evaluator.go index d71b8f9f1f..c933263a02 100644 --- a/apps/backend/internal/automation/evaluator.go +++ b/apps/backend/internal/automation/evaluator.go @@ -18,9 +18,9 @@ const defaultGitHubPollInterval = 60 * time.Second // GitHubEvaluator polls GitHub for events matching automation triggers. // It handles github_pr, github_push, and github_ci trigger types. type GitHubEvaluator struct { - svc *Service - ghClient github.Client - logger *logger.Logger + svc *Service + githubService *github.Service + logger *logger.Logger cancel context.CancelFunc wg sync.WaitGroup @@ -28,17 +28,17 @@ type GitHubEvaluator struct { } // NewGitHubEvaluator creates a new GitHub trigger evaluator. -func NewGitHubEvaluator(svc *Service, ghClient github.Client, log *logger.Logger) *GitHubEvaluator { +func NewGitHubEvaluator(svc *Service, githubService *github.Service, log *logger.Logger) *GitHubEvaluator { return &GitHubEvaluator{ - svc: svc, - ghClient: ghClient, - logger: log, + svc: svc, + githubService: githubService, + logger: log, } } // Start begins the polling loop. func (e *GitHubEvaluator) Start(ctx context.Context) { - if e.started || e.ghClient == nil { + if e.started || e.githubService == nil { return } e.started = true @@ -111,11 +111,19 @@ func (e *GitHubEvaluator) checkPRTrigger(ctx context.Context, t *AutomationTrigg if len(repos) == 0 { return // Cannot poll without specific repos } + automation, err := e.svc.GetAutomation(ctx, t.AutomationID) + if err != nil || automation == nil || automation.WorkspaceID == "" { + e.logger.Debug("automation trigger is missing workspace ownership", + zap.String("trigger_id", t.ID), zap.Error(err)) + return + } for _, repo := range repos { // Use ListReviewRequestedPRs with a custom query scoped to this repo. query := fmt.Sprintf("is:pr is:open repo:%s/%s", repo.Owner, repo.Name) - prs, err := e.ghClient.ListReviewRequestedPRs(ctx, "", "", query) + prs, err := e.githubService.ListAutomationPRs( + ctx, automation.WorkspaceID, repo.Owner, repo.Name, query, + ) if err != nil { e.logger.Debug("failed to list PRs for automation trigger", zap.String("repo", repo.Owner+"/"+repo.Name), diff --git a/apps/backend/internal/automation/provider.go b/apps/backend/internal/automation/provider.go index d0e78d85db..44e41e772a 100644 --- a/apps/backend/internal/automation/provider.go +++ b/apps/backend/internal/automation/provider.go @@ -44,11 +44,7 @@ func Provide( svc := NewService(store, eventBus, log) scheduler := NewCronScheduler(svc, log) - var ghClient github.Client - if ghSvc != nil { - ghClient = ghSvc.Client() - } - evaluator := NewGitHubEvaluator(svc, ghClient, log) + evaluator := NewGitHubEvaluator(svc, ghSvc, log) return &Components{ Service: svc, diff --git a/apps/backend/internal/backendapp/adapters_test.go b/apps/backend/internal/backendapp/adapters_test.go index 1367811c9f..d89094c324 100644 --- a/apps/backend/internal/backendapp/adapters_test.go +++ b/apps/backend/internal/backendapp/adapters_test.go @@ -224,7 +224,14 @@ func TestResolveForReviewRedetectsStoredMasterAfterClonePath(t *testing.T) { t.Fatalf("CreateWorkspace: %v", err) } basePath := t.TempDir() - repoPath := filepath.Join(basePath, "owner", "repo") + cloner := repoclone.NewCloner( + repoclone.Config{BasePath: basePath}, repoclone.ProtocolHTTPS, "", newTestLogger(), + ) + cloner.SetGitCredentialProvider(staticRepoCloneCredential{}) + repoPath, err := cloner.WorkspaceRepoPath(workspace.ID, "github", "owner", "repo") + if err != nil { + t.Fatalf("WorkspaceRepoPath: %v", err) + } gitDir := filepath.Join(repoPath, ".git") if err := os.MkdirAll(filepath.Join(gitDir, "refs", "remotes", "origin"), 0o755); err != nil { t.Fatalf("mkdir origin refs: %v", err) @@ -260,7 +267,7 @@ func TestResolveForReviewRedetectsStoredMasterAfterClonePath(t *testing.T) { } adapter := &repositoryResolverAdapter{ - cloner: repoclone.NewCloner(repoclone.Config{BasePath: basePath}, repoclone.ProtocolHTTPS, "", newTestLogger()), + cloner: cloner, taskSvc: harness.taskSvc, logger: newTestLogger(), } @@ -287,6 +294,18 @@ func TestResolveForReviewRedetectsStoredMasterAfterClonePath(t *testing.T) { } } +type staticRepoCloneCredential struct{} + +func (staticRepoCloneCredential) ResolveGitCredential( + context.Context, + string, + string, + string, + string, +) (string, string, error) { + return "x-access-token", "test-token", nil +} + func TestGetSessionModel_Caching(t *testing.T) { // We can't easily instantiate a full taskservice.Service here without a DB, // so we test the caching mechanism directly on the adapter struct. diff --git a/apps/backend/internal/backendapp/e2e_reset.go b/apps/backend/internal/backendapp/e2e_reset.go index e6f4327320..44bc4ce428 100644 --- a/apps/backend/internal/backendapp/e2e_reset.go +++ b/apps/backend/internal/backendapp/e2e_reset.go @@ -2,6 +2,7 @@ package backendapp import ( "context" + "database/sql" "net/http" "os" "strings" @@ -100,6 +101,19 @@ func handleE2EReset( if _, err := repo.DB().ExecContext(ctx, `DELETE FROM github_workspace_settings WHERE workspace_id = ?`, workspaceID); err != nil { log.Warn("e2e reset: GitHub workspace settings cleanup failed", zap.Error(err)) } + if err := deleteGitHubAuthForReset(ctx, repo.DB(), workspaceID); err != nil { + log.Error("e2e reset: GitHub authentication cleanup failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{errKey: "GitHub authentication cleanup failed"}) + return + } + if githubSvc != nil { + githubSvc.ResetMockAuth(workspaceID) + if err := resetGitHubAppRegistrationsForE2E(ctx, githubSvc, workspaceID); err != nil { + log.Error("e2e reset: GitHub deployment App cleanup failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{errKey: "GitHub deployment App cleanup failed"}) + return + } + } // The workflow-sync poller reads these rows globally; delete before // task/workflow deletion so a mid-reset tick can't resync workflows. // The table always exists, so a failure is a genuine DB error — abort @@ -214,6 +228,42 @@ func handleE2EReset( } } +func resetGitHubAppRegistrationsForE2E( + ctx context.Context, + service *github.Service, + workspaceID string, +) error { + if service == nil { + return nil + } + return service.ResetAppRegistrationsForE2E(ctx, workspaceID) +} + +func deleteGitHubAuthForReset(ctx context.Context, database *sql.DB, workspaceID string) error { + tx, err := database.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + for _, query := range []string{ + `DELETE FROM github_auth_flows WHERE workspace_id = ?`, + `DELETE FROM github_user_connections WHERE workspace_id = ?`, + `DELETE FROM github_workspace_connections WHERE workspace_id = ?`, + } { + if _, err := tx.ExecContext(ctx, query, workspaceID); err != nil { + return err + } + } + userSecretPrefix := "github:user:" + workspaceID + ":" + if _, err := tx.ExecContext(ctx, ` + DELETE FROM secrets + WHERE id = ? OR substr(id, 1, length(?)) = ?`, + github.WorkspacePATSecretKey(workspaceID), userSecretPrefix, userSecretPrefix); err != nil { + return err + } + return tx.Commit() +} + func deleteAutomationsForReset( ctx context.Context, automationSvc *automation.Service, diff --git a/apps/backend/internal/backendapp/e2e_reset_test.go b/apps/backend/internal/backendapp/e2e_reset_test.go new file mode 100644 index 0000000000..2473b352f6 --- /dev/null +++ b/apps/backend/internal/backendapp/e2e_reset_test.go @@ -0,0 +1,71 @@ +package backendapp + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" +) + +func TestE2EResetDeletesWorkspaceGitHubAuthentication(t *testing.T) { + raw, err := db.OpenSQLite(filepath.Join(t.TempDir(), "e2e-reset.db")) + if err != nil { + t.Fatalf("open database: %v", err) + } + database := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = database.Close() }) + if _, err := database.Exec(` + CREATE TABLE github_workspace_connections (workspace_id TEXT PRIMARY KEY); + CREATE TABLE github_user_connections (workspace_id TEXT, user_id TEXT); + CREATE TABLE github_auth_flows (state_hash TEXT PRIMARY KEY, workspace_id TEXT); + CREATE TABLE secrets (id TEXT PRIMARY KEY); + INSERT INTO github_workspace_connections VALUES ('ws-1'), ('ws-2'); + INSERT INTO github_user_connections VALUES ('ws-1', 'user-1'), ('ws-2', 'user-2'); + INSERT INTO github_auth_flows VALUES ('state-1', 'ws-1'), ('state-2', 'ws-2'); + INSERT INTO secrets VALUES + ('github:workspace:ws-1:pat'), + ('github:user:ws-1:user-1:access'), + ('github:user:ws-1:user-1:refresh'), + ('github:workspace:ws-2:pat') + `); err != nil { + t.Fatalf("seed database: %v", err) + } + + if err := deleteGitHubAuthForReset(context.Background(), database.DB, "ws-1"); err != nil { + t.Fatalf("delete GitHub auth: %v", err) + } + for _, table := range []string{ + "github_workspace_connections", "github_user_connections", "github_auth_flows", + } { + assertWorkspaceRows(t, database, table, "ws-1", 0) + assertWorkspaceRows(t, database, table, "ws-2", 1) + } + var ws1Secrets, ws2Secrets int + if err := database.Get(&ws1Secrets, + `SELECT COUNT(*) FROM secrets WHERE id = ? OR substr(id, 1, length(?)) = ?`, + "github:workspace:ws-1:pat", "github:user:ws-1:", "github:user:ws-1:"); err != nil { + t.Fatalf("count deleted secrets: %v", err) + } + if err := database.Get(&ws2Secrets, + `SELECT COUNT(*) FROM secrets WHERE id = ? OR substr(id, 1, length(?)) = ?`, + "github:workspace:ws-2:pat", "github:user:ws-2:", "github:user:ws-2:"); err != nil { + t.Fatalf("count preserved secrets: %v", err) + } + if ws1Secrets != 0 || ws2Secrets != 1 { + t.Fatalf("secret counts = ws-1:%d ws-2:%d, want 0 and 1", ws1Secrets, ws2Secrets) + } +} + +func assertWorkspaceRows(t *testing.T, database *sqlx.DB, table, workspaceID string, want int) { + t.Helper() + var got int + if err := database.Get(&got, `SELECT COUNT(*) FROM `+table+` WHERE workspace_id = ?`, workspaceID); err != nil { + t.Fatalf("count %s rows: %v", table, err) + } + if got != want { + t.Fatalf("%s rows for %s = %d, want %d", table, workspaceID, got, want) + } +} diff --git a/apps/backend/internal/backendapp/gateway.go b/apps/backend/internal/backendapp/gateway.go index 6eadc14157..5267b1272d 100644 --- a/apps/backend/internal/backendapp/gateway.go +++ b/apps/backend/internal/backendapp/gateway.go @@ -190,7 +190,15 @@ func provideGateway( }, } if githubSvc != nil { - router.associateGitHub = githubSvc.AssociatePRByURL + router.associateGitHub = func( + ctx context.Context, + workspaceID, sessionID, taskID, repositoryID, prURL, branch string, + ) error { + return githubSvc.AssociatePRByURLForWorkspace( + ctx, workspaceID, github.DefaultUserID, + sessionID, taskID, repositoryID, prURL, branch, + ) + } } if gitlabSvc != nil { router.associateGitLab = func(ctx context.Context, workspaceID, taskID, repositoryID, mrURL string) error { @@ -388,7 +396,7 @@ func clarificationNotificationOccurrence(data map[string]interface{}) (taskID, s type createdChangeAssociationRouter struct { resolveRepositoryID func(context.Context, string, string) string resolveWorkspaceID func(context.Context, string) (string, error) - associateGitHub func(context.Context, string, string, string, string, string) + associateGitHub func(context.Context, string, string, string, string, string, string) error associateGitLab func(context.Context, string, string, string, string) error } @@ -417,9 +425,17 @@ func (r createdChangeAssociationRouter) associate( } return r.associateGitLab(ctx, workspaceID, taskID, repositoryID, changeURL) case "github", "": - if r.associateGitHub != nil { - r.associateGitHub(ctx, sessionID, taskID, repositoryID, changeURL, branch) + if r.associateGitHub == nil { + return nil } + if r.resolveWorkspaceID == nil { + return fmt.Errorf("GitHub workspace resolver unavailable") + } + workspaceID, err := r.resolveWorkspaceID(ctx, taskID) + if err != nil { + return err + } + return r.associateGitHub(ctx, workspaceID, sessionID, taskID, repositoryID, changeURL, branch) } return nil } diff --git a/apps/backend/internal/backendapp/gateway_git_association_test.go b/apps/backend/internal/backendapp/gateway_git_association_test.go index 7ec00cc064..713e10f883 100644 --- a/apps/backend/internal/backendapp/gateway_git_association_test.go +++ b/apps/backend/internal/backendapp/gateway_git_association_test.go @@ -88,14 +88,18 @@ func TestCreatedChangeAssociationRouterPreservesGitHubAndMissingServices(t *test var got string router := createdChangeAssociationRouter{ resolveRepositoryID: func(context.Context, string, string) string { return "repo-1" }, - associateGitHub: func(_ context.Context, sessionID, taskID, repositoryID, prURL, branch string) { - got = sessionID + "|" + taskID + "|" + repositoryID + "|" + prURL + "|" + branch + resolveWorkspaceID: func(context.Context, string) (string, error) { return "workspace-1", nil }, + associateGitHub: func( + _ context.Context, workspaceID, sessionID, taskID, repositoryID, prURL, branch string, + ) error { + got = workspaceID + "|" + sessionID + "|" + taskID + "|" + repositoryID + "|" + prURL + "|" + branch + return nil }, } if err := router.associate(context.Background(), "session-1", "task-1", "github", "https://github.com/g/r/pull/2", "feature", ""); err != nil { t.Fatal(err) } - if got != "session-1|task-1|repo-1|https://github.com/g/r/pull/2|feature" { + if got != "workspace-1|session-1|task-1|repo-1|https://github.com/g/r/pull/2|feature" { t.Fatalf("GitHub association = %q", got) } if err := router.associate(context.Background(), "session-1", "task-1", "gitlab", "url", "feature", ""); err != nil { diff --git a/apps/backend/internal/backendapp/github_deployment_app_test.go b/apps/backend/internal/backendapp/github_deployment_app_test.go new file mode 100644 index 0000000000..581f6787cf --- /dev/null +++ b/apps/backend/internal/backendapp/github_deployment_app_test.go @@ -0,0 +1,109 @@ +package backendapp + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/integrations/secretadapter" + "github.com/kandev/kandev/internal/secrets" +) + +func TestGitHubAppBootLoadsCatalogRegistrationFromEncryptedStore(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + connection, err := db.OpenSQLite(filepath.Join(dir, "kandev.db")) + if err != nil { + t.Fatal(err) + } + database := sqlx.NewDb(connection, "sqlite3") + t.Cleanup(func() { _ = database.Close() }) + masterKey, err := secrets.NewMasterKeyProvider(dir) + if err != nil { + t.Fatal(err) + } + secretStore, cleanup, err := secrets.Provide(database, database, masterKey) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cleanup() }) + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatal(err) + } + seedService, cleanupGitHub, err := github.Provide(database, database, nil, nil, log) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cleanupGitHub() }) + adapter := secretadapter.New(secretStore) + repository := github.NewAppRegistrationRepository(seedService.TestStore(), adapter) + registration := &github.AppRegistration{ + ID: "11111111-1111-4111-8111-111111111111", Source: github.AppRegistrationSourceManaged, + DisplayName: "Kandev Acme", GitHubHost: "github.com", AppID: 123, + ClientID: "Iv1.client", Slug: "kandev-acme", + OwnerLogin: "acme", OwnerType: github.DeploymentAppOwnerOrganization, + Visibility: github.AppRegistrationVisibilityPrivate, + PublicBaseURL: "https://kandev.example", CredentialGeneration: 1, + Status: github.AppRegistrationStatusActive, WebhookStatus: github.DeploymentAppWebhookUnverified, + } + privateKey := testGitHubDeploymentPrivateKey(t) + if err := repository.SaveRegistration(ctx, registration, github.DeploymentAppCredentials{ + PrivateKey: privateKey, ClientSecret: "client-secret", + WebhookSecret: "webhook-secret", + }); err != nil { + t.Fatal(err) + } + const orphanID = github.AppRegistrationCredentialsSecretPrefix + "orphan:g99:secret" + if err := adapter.Set(ctx, orphanID, "orphan", `{"version":1}`); err != nil { + t.Fatal(err) + } + + service := initGitHubService( + &config.Config{}, db.NewPool(database, database), nil, secretStore, log, + ) + if service == nil { + t.Fatal("GitHub service was not initialized") + } + snapshot := service.AppRegistrationRuntimeSnapshot(registration.ID) + if !snapshot.Ready || snapshot.Source != github.DeploymentAppSourceManaged || + snapshot.AppID != registration.AppID || snapshot.Generation != 1 { + t.Fatalf("boot runtime = %+v", snapshot) + } + if exists, err := adapter.Exists(ctx, orphanID); err != nil || exists { + t.Fatalf("orphan credential exists = %v, error = %v", exists, err) + } + var encrypted []byte + if err := database.Get(&encrypted, `SELECT encrypted_value FROM secrets WHERE id = ?`, + registration.CredentialSecretID); err != nil { + t.Fatal(err) + } + for _, plaintext := range [][]byte{[]byte("client-secret"), []byte("webhook-secret"), []byte(privateKey)} { + if bytes.Contains(encrypted, plaintext) { + t.Fatal("deployment credentials were stored as plaintext") + } + } +} + +func testGitHubDeploymentPrivateKey(t *testing.T) string { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + return string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key), + })) +} diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index d6548008ad..6a5655e824 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -889,7 +889,17 @@ func registerTaskRoutes(p routeParams, planService *taskservice.PlanService, han // primary repository (first task_repository row). Resolve to that // repository_id so the resulting TaskPR/PRWatch are scoped per-repo. repositoryID := resolvePrimaryTaskRepositoryID(ctx, p.taskRepo, taskID, p.log) - ghSvc.AssociatePRByURL(ctx, sessionID, taskID, repositoryID, prURL, branch) + task, taskErr := p.taskRepo.GetTask(ctx, taskID) + if taskErr != nil || task == nil || task.WorkspaceID == "" { + p.log.Warn("cannot associate GitHub PR without task workspace", zap.String("task_id", taskID), zap.Error(taskErr)) + return + } + if err := ghSvc.AssociatePRByURLForWorkspace( + ctx, task.WorkspaceID, github.DefaultUserID, + sessionID, taskID, repositoryID, prURL, branch, + ); err != nil { + p.log.Warn("failed to associate task GitHub PR", zap.String("task_id", taskID), zap.Error(err)) + } }) } taskhandlers.RegisterRepositoryRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) @@ -1234,7 +1244,7 @@ func registerHealthRoutes(p routeParams) { var githubProvider health.GitHubStatusProvider var githubRateProvider health.GitHubRateLimitProvider if p.services.GitHub != nil { - githubProvider = p.services.GitHub + githubProvider = githubWorkspaceHealthAdapter{svc: p.services.GitHub} githubRateProvider = githubRateLimitAdapter{svc: p.services.GitHub} } githubChecker := health.NewGitHubChecker(githubProvider) @@ -1258,6 +1268,30 @@ func registerHealthRoutes(p routeParams) { health.RegisterRoutes(p.router, healthSvc, p.log) } +type githubWorkspaceHealthAdapter struct { + svc *github.Service +} + +func (a githubWorkspaceHealthAdapter) GitHubConnectionHealth( + ctx context.Context, +) (health.GitHubConnectionHealth, error) { + if a.svc == nil { + return health.GitHubConnectionHealth{}, github.ErrGitHubNotConfigured + } + summary, err := a.svc.GetWorkspaceConnectionHealth(ctx) + if err != nil { + return health.GitHubConnectionHealth{}, err + } + return health.GitHubConnectionHealth{ + WorkspaceCount: summary.WorkspaceCount, + Active: summary.Active, + Disconnected: summary.Disconnected, + Invalid: summary.Invalid, + Suspended: summary.Suspended, + Revoked: summary.Revoked, + }, nil +} + // githubRateLimitAdapter bridges the github.Service's per-resource exhaustion // snapshot to the structural shape consumed by the health package without // importing health into github (cycle). diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 62dc5b13b9..69b28b18f6 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -410,10 +410,11 @@ func startAgentInfrastructure( agentctlBinaryPath string, runCleanups func(), ) bool { + userSecretStore := secrets.NewUserVisibleStore(repos.Secrets) // ============================================ // AGENT MANAGER // ============================================ - lifecycleMgr, err := provideLifecycleManager(ctx, cfg, log, eventBus, repos.AgentSettings, agentRegistry, repos.Secrets) + lifecycleMgr, err := provideLifecycleManager(ctx, cfg, log, eventBus, repos.AgentSettings, agentRegistry, userSecretStore) if err != nil { log.Error("Failed to initialize agent manager", zap.Error(err)) return false @@ -472,6 +473,9 @@ func startAgentInfrastructure( repoCloner := repoclone.NewCloner(repoclone.Config{ BasePath: cfg.RepoClone.BasePath, }, repoclone.DetectGitProtocol(), cfg.ResolvedHomeDir(), log) + if services.GitHub != nil { + repoCloner.SetGitCredentialProvider(services.GitHub) + } log.Info("Repository cloner configured", zap.String("base_path", cfg.RepoClone.BasePath)) @@ -488,7 +492,7 @@ func startAgentInfrastructure( log.Info("Initializing Orchestrator...") orchestratorSvc, msgCreator, err := provideOrchestrator(cfg, log, dbPool, eventBus, repos.Task, services.Task, services.User, - lifecycleMgr, agentRegistry, services.Workflow, repos.Secrets, repoCloner, services.Prompts) + lifecycleMgr, agentRegistry, services.Workflow, userSecretStore, repoCloner, services.Prompts, services.GitHub) if err != nil { log.Error("Failed to initialize orchestrator", zap.Error(err)) return false @@ -1733,6 +1737,7 @@ func buildHTTPServer( if err != nil { return nil, fmt.Errorf("generate interim settings interlock token: %w", err) } + userSecretStore := secrets.NewUserVisibleStore(repos.Secrets) // Opt-in authentication. Runs after CORS; in disabled mode it only // injects the synthetic single-user identity (behavior unchanged). @@ -1767,8 +1772,8 @@ func buildHTTPServer( promptCtrl: promptcontroller.NewController(services.Prompts), utilityCtrl: utilitycontroller.NewController(services.Utility), msgCreator: msgCreator, - secretsSvc: secrets.NewService(repos.Secrets, log), - secretStore: repos.Secrets, + secretsSvc: secrets.NewService(userSecretStore, log), + secretStore: userSecretStore, mcpConfigSvc: mcpconfig.NewService(repos.AgentSettings), authSvc: services.Auth, addCleanup: addCleanup, diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index 7278a44128..8ef958290a 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -27,6 +27,7 @@ import ( linearpkg "github.com/kandev/kandev/internal/linear" officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" "github.com/kandev/kandev/internal/orchestrator" + executorpkg "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/messagequeue" promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/repoclone" @@ -61,6 +62,7 @@ func provideOrchestrator( secretStore secrets.SecretStore, repoCloner *repoclone.Cloner, promptSvc *promptservice.Service, + githubSvc *githubpkg.Service, ) (*orchestrator.Service, *messageCreatorAdapter, error) { if lifecycleMgr == nil { return nil, nil, errors.New("lifecycle manager is required: configure agent runtime (docker or standalone)") @@ -92,6 +94,12 @@ func provideOrchestrator( zap.Int("max_per_session", maxPerSession)) orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, userSvc, secretStore, msgQueue, log) + if githubSvc != nil { + orchestratorSvc.SetGitHubCredentialBroker( + githubExecutorCredentialLeaseAdapter{service: githubSvc}, + githubCredentialBrokerEndpoint(cfg), + ) + } taskSvc.SetExecutionStopper(orchestratorSvc) taskSvc.SetGitArchiveCapture(orchestratorSvc) orchestratorSvc.SetWorktreeManager(lifecycleMgr.WorktreeManager()) @@ -162,6 +170,43 @@ func provideOrchestrator( return orchestratorSvc, msgCreator, nil } +type githubCredentialLeaseService interface { + IssueGitHubCredentialLease(context.Context, githubpkg.CredentialLeaseRequest) (*githubpkg.CredentialLease, error) +} + +type githubExecutorCredentialLeaseAdapter struct { + service githubCredentialLeaseService +} + +func (a githubExecutorCredentialLeaseAdapter) IssueGitHubCredentialLease( + ctx context.Context, + request executorpkg.GitHubCredentialLeaseRequest, +) (executorpkg.GitHubCredentialLease, error) { + lease, err := a.service.IssueGitHubCredentialLease(ctx, githubpkg.CredentialLeaseRequest{ + WorkspaceID: request.WorkspaceID, TaskID: request.TaskID, SessionID: request.SessionID, + RepositoryID: request.RepositoryID, Owner: request.Owner, Repo: request.Repo, Host: request.Host, + }) + if err != nil { + return executorpkg.GitHubCredentialLease{}, err + } + if lease == nil { + return executorpkg.GitHubCredentialLease{}, errors.New("GitHub credential broker returned no lease") + } + return executorpkg.GitHubCredentialLease{Token: lease.Token}, nil +} + +func githubCredentialBrokerEndpoint(cfg *config.Config) string { + if cfg != nil { + if publicBaseURL := strings.TrimRight(strings.TrimSpace(cfg.GitHubCredentialBroker.PublicBaseURL), "/"); publicBaseURL != "" { + return publicBaseURL + "/api/v1/github/credentials/resolve" + } + if cfg.Server.Port != 0 { + return fmt.Sprintf("http://localhost:%d/api/v1/github/credentials/resolve", cfg.Server.Port) + } + } + return fmt.Sprintf("http://localhost:%d/api/v1/github/credentials/resolve", portsBackendDefault) +} + // resolveQueueMaxPerSession honors the KANDEV_QUEUE_MAX_PER_SESSION env var, // falling back to messagequeue.DefaultMaxPerSession (10) when unset or invalid. // Values <= 0 disable the cap entirely (callers can still flood queues — only @@ -717,7 +762,7 @@ func (a *repositoryResolverAdapter) ResolveForReview( return "", "", fmt.Errorf("unsupported provider: %w", err) } - localPath, err := a.cloner.EnsureCloned(ctx, cloneURL, owner, name) + localPath, err := a.cloner.EnsureWorkspaceCloned(ctx, workspaceID, provider, cloneURL, owner, name) if err != nil { return "", "", fmt.Errorf("clone repository: %w", err) } diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 2f6c2ba99c..9059702326 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "path/filepath" + "strings" "go.uber.org/zap" @@ -108,9 +109,12 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories buildAgentProfileMatcher(repos), ) - githubSvc := initGitHubService(dbPool, eventBus, repos.Secrets, log) + githubSvc := initGitHubService(cfg, dbPool, eventBus, repos.Secrets, log) if githubSvc != nil { githubSvc.SetPromptResolver(promptSvc) + if brokerErr := githubSvc.ConfigureCredentialBroker(&githubBrokerScopeAuthorizer{repo: repos.Task}); brokerErr != nil { + log.Warn("GitHub credential broker initialization failed", zap.Error(brokerErr)) + } } gitlabSvc := initGitLabService(dbPool, eventBus, repos.Secrets, log) azureDevOpsSvc := initAzureDevOpsService(dbPool, eventBus, repos.Secrets, log) @@ -135,6 +139,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories if githubSvc != nil { taskSvc.SetRemoteBranchLister(githubBranchListerAdapter{svc: githubSvc}) taskSvc.SetPRTaskResolver(githubSvc) + githubSvc.SetWorkspaceAuthorizer(taskSvc.AuthorizeWorkspaceAccess) } // Initialize Automation service @@ -184,6 +189,96 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories return services, agentSettingsController, nil } +type githubBrokerScopeAuthorizer struct { + repo interface { + GetTask(context.Context, string) (*taskmodels.Task, error) + GetTaskSession(context.Context, string) (*taskmodels.TaskSession, error) + GetRepository(context.Context, string) (*taskmodels.Repository, error) + ListTaskRepositories(context.Context, string) ([]*taskmodels.TaskRepository, error) + } +} + +func (a *githubBrokerScopeAuthorizer) AuthorizeGitHubRepository( + ctx context.Context, + workspaceID, taskID, sessionID, repositoryID, owner, repoName string, +) error { + if a == nil || a.repo == nil { + return fmt.Errorf("task repository is unavailable") + } + if err := a.authorizeTaskSession(ctx, workspaceID, taskID, sessionID); err != nil { + return err + } + if err := a.authorizeTaskRepository(ctx, taskID, repositoryID); err != nil { + return err + } + return a.authorizeRepositoryIdentity(ctx, workspaceID, repositoryID, owner, repoName) +} + +func (a *githubBrokerScopeAuthorizer) authorizeTaskSession( + ctx context.Context, + workspaceID, taskID, sessionID string, +) error { + task, err := a.repo.GetTask(ctx, taskID) + if err != nil { + return err + } + if task == nil || task.WorkspaceID != workspaceID { + return fmt.Errorf("task does not belong to workspace") + } + session, err := a.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return err + } + if session == nil || session.TaskID != taskID { + return fmt.Errorf("session does not belong to task") + } + switch session.State { + case taskmodels.TaskSessionStateCompleted, + taskmodels.TaskSessionStateFailed, + taskmodels.TaskSessionStateCancelled: + return fmt.Errorf("session is terminal") + } + return nil +} + +func (a *githubBrokerScopeAuthorizer) authorizeTaskRepository( + ctx context.Context, + taskID, repositoryID string, +) error { + links, err := a.repo.ListTaskRepositories(ctx, taskID) + if err != nil { + return err + } + linked := false + for _, link := range links { + if link != nil && link.RepositoryID == repositoryID { + linked = true + break + } + } + if !linked { + return fmt.Errorf("repository is not linked to task") + } + return nil +} + +func (a *githubBrokerScopeAuthorizer) authorizeRepositoryIdentity( + ctx context.Context, + workspaceID, repositoryID, owner, repoName string, +) error { + repository, err := a.repo.GetRepository(ctx, repositoryID) + if err != nil { + return err + } + if repository == nil || repository.WorkspaceID != workspaceID || + !strings.EqualFold(repository.Provider, "github") || + !strings.EqualFold(repository.ProviderOwner, owner) || + !strings.EqualFold(repository.ProviderName, repoName) { + return fmt.Errorf("repository identity does not match lease scope") + } + return nil +} + // loadCustomTUIAgents loads user-defined TUI agents from the database into the registry. // Non-fatal: logs warnings but continues if any individual agent fails. func loadCustomTUIAgents(ctx context.Context, repos *Repositories, agentRegistry *registry.Registry, log *logger.Logger) { @@ -294,7 +389,13 @@ func (a *githubSecretAdapter) Delete(ctx context.Context, id string) error { // initGitHubService wires up the GitHub integration. Failures are non-fatal: // the rest of the backend still boots without GitHub configured. -func initGitHubService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *github.Service { +func initGitHubService( + cfg *config.Config, + dbPool *db.Pool, + eventBus bus.EventBus, + secretsStore secrets.SecretStore, + log *logger.Logger, +) *github.Service { adapter := &githubSecretAdapter{store: secretsStore} svc, _, err := github.Provide(dbPool.Writer(), dbPool.Reader(), adapter, eventBus, log) if err != nil { @@ -305,6 +406,13 @@ func initGitHubService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secr // (mutating) — same adapter satisfies both interfaces, but the // service needs the mutating one wired explicitly. svc.SetSecretManager(adapter) + svc.SetConnectionSecretStore(secretadapter.New(secretsStore)) + if authErr := svc.InitializeAppRegistrationLifecycle(); authErr != nil { + log.Warn("GitHub App registration lifecycle failed to initialize", zap.Error(authErr)) + } + if authErr := svc.InitializeAppRegistrationRuntimes(context.Background()); authErr != nil { + log.Warn("one or more GitHub App registrations failed to initialize", zap.Error(authErr)) + } } return svc } @@ -475,8 +583,7 @@ func initSentryService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secr // initShareHandlers wires up the public-share-links HTTP surface. Failures // are non-fatal: the rest of the backend boots without the share endpoints. -// The github.Client may be nil; CreateShare will fail at the IsAuthenticated -// probe with a 412 in that case. +// GitHub access resolves from the owning task workspace on every operation. func initShareHandlers( dbPool *db.Pool, taskRepo share.TaskReader, @@ -484,14 +591,10 @@ func initShareHandlers( log *logger.Logger, version string, ) *share.HTTPHandlers { - var ghClient github.Client - if githubSvc != nil { - ghClient = githubSvc.Client() - } - if ghClient == nil { - ghClient = &github.NoopClient{} - } - h, _, err := share.Provide(dbPool.Writer(), dbPool.Reader(), taskRepo, ghClient, log, share.Config{KandevVersion: version}) + h, _, err := share.Provide( + dbPool.Writer(), dbPool.Reader(), taskRepo, githubSvc, log, + share.Config{KandevVersion: version}, + ) if err != nil { log.Warn("Share handlers initialization failed (non-fatal)", zap.Error(err)) return nil @@ -686,8 +789,10 @@ type githubBranchListerAdapter struct { svc *github.Service } -func (a githubBranchListerAdapter) ListRepoBranches(ctx context.Context, owner, repo string) ([]taskservice.Branch, error) { - remote, err := a.svc.ListRepoBranches(ctx, owner, repo) +func (a githubBranchListerAdapter) ListRepoBranches( + ctx context.Context, workspaceID, owner, repo string, +) ([]taskservice.Branch, error) { + remote, err := a.svc.ListRepoBranchesForWorkspace(ctx, workspaceID, owner, repo) if err != nil { return nil, err } diff --git a/apps/backend/internal/backendapp/services_github_broker_test.go b/apps/backend/internal/backendapp/services_github_broker_test.go new file mode 100644 index 0000000000..17386d26e8 --- /dev/null +++ b/apps/backend/internal/backendapp/services_github_broker_test.go @@ -0,0 +1,109 @@ +package backendapp + +import ( + "context" + "strings" + "testing" + + "github.com/kandev/kandev/internal/common/config" + githubpkg "github.com/kandev/kandev/internal/github" + executorpkg "github.com/kandev/kandev/internal/orchestrator/executor" + taskmodels "github.com/kandev/kandev/internal/task/models" +) + +type fakeGitHubCredentialLeaseService struct { + request githubpkg.CredentialLeaseRequest +} + +func (s *fakeGitHubCredentialLeaseService) IssueGitHubCredentialLease( + _ context.Context, + request githubpkg.CredentialLeaseRequest, +) (*githubpkg.CredentialLease, error) { + s.request = request + return &githubpkg.CredentialLease{Token: "lease-token"}, nil +} + +func TestGitHubExecutorCredentialLeaseAdapterMapsScope(t *testing.T) { + service := &fakeGitHubCredentialLeaseService{} + adapter := githubExecutorCredentialLeaseAdapter{service: service} + lease, err := adapter.IssueGitHubCredentialLease(context.Background(), executorpkg.GitHubCredentialLeaseRequest{ + WorkspaceID: "workspace-1", TaskID: "task-1", SessionID: "session-1", + RepositoryID: "repository-1", Owner: "kdlbs", Repo: "kandev", Host: "github.com", + }) + if err != nil { + t.Fatal(err) + } + if lease.Token != "lease-token" || service.request.SessionID != "session-1" || service.request.RepositoryID != "repository-1" { + t.Fatalf("lease = %+v, request = %+v", lease, service.request) + } +} + +func TestGitHubCredentialBrokerEndpoint(t *testing.T) { + dedicated := &config.Config{ + GitHubCredentialBroker: config.GitHubCredentialBrokerConfig{PublicBaseURL: "https://broker.example/"}, + } + if got, want := githubCredentialBrokerEndpoint(dedicated), "https://broker.example/api/v1/github/credentials/resolve"; got != want { + t.Fatalf("dedicated endpoint = %q, want %q", got, want) + } + local := &config.Config{Server: config.ServerConfig{Port: 49123}} + if got, want := githubCredentialBrokerEndpoint(local), "http://localhost:49123/api/v1/github/credentials/resolve"; got != want { + t.Fatalf("local endpoint = %q, want %q", got, want) + } +} + +type fakeGitHubBrokerTaskRepository struct { + task *taskmodels.Task + session *taskmodels.TaskSession + repository *taskmodels.Repository + links []*taskmodels.TaskRepository +} + +func (r *fakeGitHubBrokerTaskRepository) GetTask(context.Context, string) (*taskmodels.Task, error) { + return r.task, nil +} + +func (r *fakeGitHubBrokerTaskRepository) GetTaskSession(context.Context, string) (*taskmodels.TaskSession, error) { + return r.session, nil +} + +func (r *fakeGitHubBrokerTaskRepository) GetRepository(context.Context, string) (*taskmodels.Repository, error) { + return r.repository, nil +} + +func (r *fakeGitHubBrokerTaskRepository) ListTaskRepositories(context.Context, string) ([]*taskmodels.TaskRepository, error) { + return r.links, nil +} + +func TestGitHubBrokerScopeAuthorizerValidatesSessionOwnershipAndState(t *testing.T) { + repo := &fakeGitHubBrokerTaskRepository{ + task: &taskmodels.Task{ID: "task-1", WorkspaceID: "workspace-1"}, + session: &taskmodels.TaskSession{ID: "session-1", TaskID: "task-1", State: taskmodels.TaskSessionStateRunning}, + repository: &taskmodels.Repository{ + ID: "repository-1", WorkspaceID: "workspace-1", Provider: "github", + ProviderOwner: "kdlbs", ProviderName: "kandev", + }, + links: []*taskmodels.TaskRepository{{TaskID: "task-1", RepositoryID: "repository-1"}}, + } + authorizer := &githubBrokerScopeAuthorizer{repo: repo} + + if err := authorizer.AuthorizeGitHubRepository( + context.Background(), "workspace-1", "task-1", "session-1", "repository-1", "kdlbs", "kandev", + ); err != nil { + t.Fatalf("AuthorizeGitHubRepository() error = %v", err) + } + + repo.session.TaskID = "another-task" + if err := authorizer.AuthorizeGitHubRepository( + context.Background(), "workspace-1", "task-1", "session-1", "repository-1", "kdlbs", "kandev", + ); err == nil || !strings.Contains(err.Error(), "session does not belong") { + t.Fatalf("session mismatch error = %v", err) + } + + repo.session.TaskID = "task-1" + repo.session.State = taskmodels.TaskSessionStateCompleted + if err := authorizer.AuthorizeGitHubRepository( + context.Background(), "workspace-1", "task-1", "session-1", "repository-1", "kdlbs", "kandev", + ); err == nil || !strings.Contains(err.Error(), "session is terminal") { + t.Fatalf("terminal session error = %v", err) + } +} diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 5dd88ec683..14c7e8b35e 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -5,6 +5,7 @@ package config import ( "fmt" "net" + "net/url" "os" "path/filepath" "runtime" @@ -28,22 +29,51 @@ type Config struct { // /.kandev-dev during local development). When empty, falls back // to ~/.kandev. All workspace artifacts (data, tasks, worktrees, repos) // live under this root. - HomeDir string `mapstructure:"homeDir"` - Server ServerConfig `mapstructure:"server"` - Database DatabaseConfig `mapstructure:"database"` - NATS NATSConfig `mapstructure:"nats"` - Events EventsConfig `mapstructure:"events"` - Docker DockerConfig `mapstructure:"docker"` - Agent AgentConfig `mapstructure:"agent"` - Auth AuthConfig `mapstructure:"auth"` - Logging LoggingConfig `mapstructure:"logging"` - RepositoryDiscovery RepositoryDiscoveryConfig `mapstructure:"repositoryDiscovery"` - Worktree WorktreeConfig `mapstructure:"worktree"` - RepoClone RepoCloneConfig `mapstructure:"repoClone"` - Debug DebugConfig `mapstructure:"debug"` - Office OfficeConfig `mapstructure:"office"` - Voice VoiceConfig `mapstructure:"voice"` - Features FeaturesConfig `mapstructure:"features"` + HomeDir string `mapstructure:"homeDir"` + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + NATS NATSConfig `mapstructure:"nats"` + Events EventsConfig `mapstructure:"events"` + Docker DockerConfig `mapstructure:"docker"` + Agent AgentConfig `mapstructure:"agent"` + Auth AuthConfig `mapstructure:"auth"` + Logging LoggingConfig `mapstructure:"logging"` + RepositoryDiscovery RepositoryDiscoveryConfig `mapstructure:"repositoryDiscovery"` + Worktree WorktreeConfig `mapstructure:"worktree"` + RepoClone RepoCloneConfig `mapstructure:"repoClone"` + Debug DebugConfig `mapstructure:"debug"` + Office OfficeConfig `mapstructure:"office"` + Voice VoiceConfig `mapstructure:"voice"` + Features FeaturesConfig `mapstructure:"features"` + GitHubCredentialBroker GitHubCredentialBrokerConfig `mapstructure:"githubCredentialBroker"` +} + +// GitHubCredentialBrokerConfig holds the externally reachable service URL +// used by managed remote executors. It is independent of GitHub App setup so +// PAT and named gh CLI workspaces can use remote executors. +type GitHubCredentialBrokerConfig struct { + PublicBaseURL string `mapstructure:"publicBaseUrl" json:"-"` +} + +func (c GitHubCredentialBrokerConfig) validate() error { + if strings.TrimSpace(c.PublicBaseURL) == "" { + return nil + } + return validatePublicBaseURL("githubCredentialBroker.publicBaseUrl", c.PublicBaseURL) +} + +func validatePublicBaseURL(field, raw string) error { + u, err := url.Parse(raw) + if err != nil || !u.IsAbs() || u.Hostname() == "" { + return fmt.Errorf("%s must be an absolute URL", field) + } + if u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return fmt.Errorf("%s must not include credentials, query, or fragment", field) + } + if u.Scheme != "https" && (u.Scheme != "http" || !IsLoopbackHost(u.Hostname())) { + return fmt.Errorf("%s must use HTTPS outside loopback development", field) + } + return nil } // expandTilde expands a leading "~/" to the user's home directory. @@ -651,6 +681,10 @@ func LoadWithPath(configPath string) (*Config, error) { _ = v.BindEnv("debug.devMode", "KANDEV_DEBUG_DEV_MODE") _ = v.BindEnv("debug.pprofEnabled", "KANDEV_DEBUG_PPROF_ENABLED") _ = v.BindEnv("voice.openAIApiKey", "KANDEV_VOICE_OPENAI_API_KEY") + _ = v.BindEnv( + "githubCredentialBroker.publicBaseUrl", + "KANDEV_GITHUB_CREDENTIAL_BROKER_PUBLIC_BASE_URL", + ) // Configure config file v.SetConfigName("config") @@ -748,6 +782,10 @@ func validate(cfg *Config) error { errs = append(errs, "repositoryDiscovery.maxDepth must be positive") } + if err := cfg.GitHubCredentialBroker.validate(); err != nil { + errs = append(errs, err.Error()) + } + if len(errs) > 0 { return fmt.Errorf("%s", strings.Join(errs, "; ")) } diff --git a/apps/backend/internal/common/config/config_test.go b/apps/backend/internal/common/config/config_test.go index 2246f40c49..29c8a7cb4e 100644 --- a/apps/backend/internal/common/config/config_test.go +++ b/apps/backend/internal/common/config/config_test.go @@ -8,6 +8,29 @@ import ( "testing" ) +func TestGitHubCredentialBrokerConfigValidation(t *testing.T) { + cfg := minimalValidConfig() + cfg.GitHubCredentialBroker.PublicBaseURL = "https://kandev.example.com" + if err := validate(cfg); err != nil { + t.Fatalf("validate broker-only config: %v", err) + } + cfg.GitHubCredentialBroker.PublicBaseURL = "http://kandev.example.com" + if err := validate(cfg); err == nil || !strings.Contains(err.Error(), "githubCredentialBroker.publicBaseUrl") { + t.Fatalf("validate insecure broker URL error = %v", err) + } +} + +func TestGitHubCredentialBrokerConfigEnvironmentBinding(t *testing.T) { + t.Setenv("KANDEV_GITHUB_CREDENTIAL_BROKER_PUBLIC_BASE_URL", "https://kandev.example.com") + cfg, err := LoadWithPath(t.TempDir()) + if err != nil { + t.Fatalf("LoadWithPath: %v", err) + } + if got := cfg.GitHubCredentialBroker.PublicBaseURL; got != "https://kandev.example.com" { + t.Fatalf("broker public base URL = %q", got) + } +} + // minimalValidConfig returns a Config that passes validate() out of the box. // Tests modify a copy to exercise individual validation branches. func minimalValidConfig() *Config { diff --git a/apps/backend/internal/github/action_presets_service.go b/apps/backend/internal/github/action_presets_service.go index 47579efe2d..1962c0ee83 100644 --- a/apps/backend/internal/github/action_presets_service.go +++ b/apps/backend/internal/github/action_presets_service.go @@ -11,6 +11,9 @@ import ( // GetActionPresets returns the stored preset lists for a workspace, falling // back to the built-in defaults for any list that hasn't been customised yet. func (s *Service) GetActionPresets(ctx context.Context, workspaceID string) (*ActionPresets, error) { + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } stored, err := s.store.GetActionPresets(ctx, workspaceID) if err != nil { return nil, err @@ -41,6 +44,9 @@ func (s *Service) UpdateActionPresets(ctx context.Context, req *UpdateActionPres if req == nil || req.WorkspaceID == "" { return nil, fmt.Errorf("workspace_id is required") } + if err := s.authorizeWorkspaceAccess(ctx, req.WorkspaceID); err != nil { + return nil, err + } stored, err := s.store.GetActionPresets(ctx, req.WorkspaceID) if err != nil { return nil, err @@ -67,6 +73,9 @@ func (s *Service) ResetActionPresets(ctx context.Context, workspaceID string) (* if workspaceID == "" { return nil, fmt.Errorf("workspace_id is required") } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } if err := s.store.DeleteActionPresets(ctx, workspaceID); err != nil { return nil, err } diff --git a/apps/backend/internal/github/app_client.go b/apps/backend/internal/github/app_client.go new file mode 100644 index 0000000000..896dc0aed9 --- /dev/null +++ b/apps/backend/internal/github/app_client.go @@ -0,0 +1,368 @@ +package github + +import ( + "bytes" + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +const maxGitHubAppResponseSize = 4 * 1024 * 1024 + +var ErrGitHubAppResponseTooLarge = errors.New("GitHub App response exceeds the allowed size") + +// PermissionLevel is a GitHub App repository permission access level. +type PermissionLevel string + +const ( + PermissionRead PermissionLevel = "read" + PermissionWrite PermissionLevel = "write" +) + +// InstallationPermissions is the permission subset requested for an +// installation access token. +type InstallationPermissions map[string]PermissionLevel + +// GitHubAppCapability is an operation-level capability derived from the +// permissions GitHub reports for an installation or token. +type GitHubAppCapability string + +const ( + CapabilityRepositoryRead GitHubAppCapability = "repository_read" + CapabilityGitRead GitHubAppCapability = "git_read" + CapabilityGitWrite GitHubAppCapability = "git_write" + CapabilityPullRequestRead GitHubAppCapability = "pull_request_read" + CapabilityPullRequestWrite GitHubAppCapability = "pull_request_write" + CapabilityIssueRead GitHubAppCapability = "issue_read" + CapabilityIssueWrite GitHubAppCapability = "issue_write" + CapabilityChecksRead GitHubAppCapability = "checks_read" + CapabilityStatusesRead GitHubAppCapability = "statuses_read" + CapabilityActionsRead GitHubAppCapability = "actions_read" + CapabilityBranchProtectionRead GitHubAppCapability = "branch_protection_read" + CapabilityMembersRead GitHubAppCapability = "members_read" + CapabilityWorkflowsWrite GitHubAppCapability = "workflows_write" +) + +// InstallationToken is a short-lived token minted for one App installation. +type InstallationToken struct { + Token string + ExpiresAt time.Time + Permissions InstallationPermissions + Principal TokenPrincipal +} + +// AppInstallation is verified installation metadata returned by GitHub. +type AppInstallation struct { + ID int64 + AccountID int64 + AccountLogin string + AccountType string + Permissions InstallationPermissions + SuspendedAt *time.Time +} + +type AuthenticatedApp struct { + ID int64 + ClientID string + Slug string + Name string + OwnerLogin string + OwnerType string + ExternalURL string + Permissions map[string]string + Events []string +} + +type AppWebhookConfig struct { + URL string + ContentType string + InsecureSSL string +} + +// AppClient authenticates as the deployment GitHub App. +type AppClient struct { + appID int64 + privateKey *rsa.PrivateKey + httpClient *http.Client + baseURL string + now func() time.Time +} + +// NewAppClient parses an RSA private key and creates a GitHub App client. +func NewAppClient(appID int64, privateKeyPEM []byte) (*AppClient, error) { + if appID <= 0 { + return nil, fmt.Errorf("GitHub App ID must be positive") + } + privateKey, err := parseAppPrivateKey(privateKeyPEM) + if err != nil { + return nil, fmt.Errorf("parse GitHub App private key: %w", err) + } + return &AppClient{ + appID: appID, + privateKey: privateKey, + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: githubAPIBase, + now: time.Now, + }, nil +} + +func parseAppPrivateKey(keyPEM []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(keyPEM) + if block == nil { + return nil, errors.New("PEM block not found") + } + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("private key is not RSA") + } + return key, nil +} + +// AppJWT creates a GitHub App JWT with the recommended one-minute clock skew +// and a lifetime below GitHub's ten-minute maximum. +func (c *AppClient) AppJWT() (string, error) { + now := c.now().UTC() + header, err := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"}) + if err != nil { + return "", err + } + claims, err := json.Marshal(map[string]any{ + "iat": now.Add(-time.Minute).Unix(), + "exp": now.Add(9 * time.Minute).Unix(), + "iss": strconv.FormatInt(c.appID, 10), + }) + if err != nil { + return "", err + } + unsigned := base64.RawURLEncoding.EncodeToString(header) + "." + + base64.RawURLEncoding.EncodeToString(claims) + digest := sha256.Sum256([]byte(unsigned)) + signature, err := rsa.SignPKCS1v15(rand.Reader, c.privateKey, crypto.SHA256, digest[:]) + if err != nil { + return "", fmt.Errorf("sign GitHub App JWT: %w", err) + } + return unsigned + "." + base64.RawURLEncoding.EncodeToString(signature), nil +} + +// GetInstallation returns App-authenticated installation metadata. +func (c *AppClient) GetInstallation(ctx context.Context, installationID int64) (AppInstallation, error) { + var response struct { + ID int64 `json:"id"` + Account struct { + ID int64 `json:"id"` + Login string `json:"login"` + Type string `json:"type"` + } `json:"account"` + Permissions map[string]string `json:"permissions"` + SuspendedAt *time.Time `json:"suspended_at"` + } + if err := c.appRequest(ctx, http.MethodGet, fmt.Sprintf("/app/installations/%d", installationID), nil, &response); err != nil { + return AppInstallation{}, err + } + return AppInstallation{ + ID: response.ID, + AccountID: response.Account.ID, + AccountLogin: response.Account.Login, + AccountType: response.Account.Type, + Permissions: permissionLevels(response.Permissions), + SuspendedAt: response.SuspendedAt, + }, nil +} + +func (c *AppClient) GetAuthenticatedApp(ctx context.Context) (AuthenticatedApp, error) { + var response struct { + ID int64 `json:"id"` + ClientID string `json:"client_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Owner struct { + Login string `json:"login"` + Type string `json:"type"` + } `json:"owner"` + ExternalURL string `json:"external_url"` + Permissions map[string]string `json:"permissions"` + Events []string `json:"events"` + } + if err := c.appRequest(ctx, http.MethodGet, "/app", nil, &response); err != nil { + return AuthenticatedApp{}, err + } + return AuthenticatedApp{ + ID: response.ID, ClientID: response.ClientID, Slug: response.Slug, Name: response.Name, + OwnerLogin: response.Owner.Login, OwnerType: response.Owner.Type, + ExternalURL: response.ExternalURL, + Permissions: response.Permissions, Events: response.Events, + }, nil +} + +func (c *AppClient) GetWebhookConfig(ctx context.Context) (AppWebhookConfig, error) { + var response struct { + URL string `json:"url"` + ContentType string `json:"content_type"` + InsecureSSL any `json:"insecure_ssl"` + } + if err := c.appRequest(ctx, http.MethodGet, "/app/hook/config", nil, &response); err != nil { + return AppWebhookConfig{}, err + } + return AppWebhookConfig{ + URL: response.URL, ContentType: response.ContentType, + InsecureSSL: fmt.Sprint(response.InsecureSSL), + }, nil +} + +// MintInstallationToken requests a permission-scoped installation token. +func (c *AppClient) MintInstallationToken( + ctx context.Context, + installationID int64, + permissions InstallationPermissions, + repositories []string, +) (InstallationToken, error) { + if installationID <= 0 { + return InstallationToken{}, fmt.Errorf("installation ID must be positive") + } + if err := validateInstallationPermissions(permissions); err != nil { + return InstallationToken{}, err + } + body := struct { + Permissions InstallationPermissions `json:"permissions,omitempty"` + Repositories []string `json:"repositories,omitempty"` + }{Permissions: permissions, Repositories: repositories} + var response struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + Permissions map[string]string `json:"permissions"` + } + if err := c.appRequest( + ctx, + http.MethodPost, + fmt.Sprintf("/app/installations/%d/access_tokens", installationID), + body, + &response, + ); err != nil { + return InstallationToken{}, err + } + if response.Token == "" { + return InstallationToken{}, errors.New("GitHub returned an empty installation token") + } + return InstallationToken{ + Token: response.Token, + ExpiresAt: response.ExpiresAt, + Permissions: permissionLevels(response.Permissions), + Principal: TokenPrincipal{ + Kind: TokenCredentialInstallation, + PrincipalID: fmt.Sprintf("installation:%d", installationID), + InstallationID: installationID, + }, + }, nil +} + +func (c *AppClient) appRequest(ctx context.Context, method, path string, body, out any) error { + jwt, err := c.AppJWT() + if err != nil { + return err + } + var requestBody io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal GitHub App request: %w", err) + } + requestBody = bytes.NewReader(encoded) + } + req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.baseURL, "/")+path, requestBody) + if err != nil { + return err + } + req.Header.Set("Accept", githubAccept) + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("X-GitHub-Api-Version", githubAPIVersion) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("GitHub App request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, maxGitHubAppResponseSize+1)) + if readErr != nil { + return fmt.Errorf("read GitHub App response: %w", readErr) + } + if len(responseBody) > maxGitHubAppResponseSize { + return ErrGitHubAppResponseTooLarge + } + if resp.StatusCode >= http.StatusBadRequest { + return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: path, Body: string(responseBody)} + } + if err := json.Unmarshal(responseBody, out); err != nil { + return fmt.Errorf("decode GitHub App response: %w", err) + } + return nil +} + +func validateInstallationPermissions(permissions InstallationPermissions) error { + for name, level := range permissions { + if strings.TrimSpace(name) == "" { + return errors.New("installation permission name must not be empty") + } + if level != PermissionRead && level != PermissionWrite { + return fmt.Errorf("invalid access level for installation permission %q", name) + } + } + return nil +} + +func permissionLevels(input map[string]string) InstallationPermissions { + permissions := make(InstallationPermissions, len(input)) + for name, level := range input { + permissions[name] = PermissionLevel(level) + } + return permissions +} + +// CapabilitiesForPermissions maps GitHub's repository permissions to Kandev +// operations. Write permission implies read permission. +func CapabilitiesForPermissions(permissions InstallationPermissions) map[GitHubAppCapability]bool { + capabilities := make(map[GitHubAppCapability]bool) + grant := func(permission string, readCapability, writeCapability GitHubAppCapability) { + level := permissions[permission] + if level == PermissionRead || level == PermissionWrite { + capabilities[readCapability] = true + } + if writeCapability != "" && level == PermissionWrite { + capabilities[writeCapability] = true + } + } + grant("metadata", CapabilityRepositoryRead, "") + grant("contents", CapabilityGitRead, CapabilityGitWrite) + grant("pull_requests", CapabilityPullRequestRead, CapabilityPullRequestWrite) + grant("issues", CapabilityIssueRead, CapabilityIssueWrite) + grant("checks", CapabilityChecksRead, "") + grant("statuses", CapabilityStatusesRead, "") + grant("actions", CapabilityActionsRead, "") + grant("administration", CapabilityBranchProtectionRead, "") + grant("members", CapabilityMembersRead, "") + if permissions["workflows"] == PermissionWrite { + capabilities[CapabilityWorkflowsWrite] = true + } + return capabilities +} diff --git a/apps/backend/internal/github/app_client_test.go b/apps/backend/internal/github/app_client_test.go new file mode 100644 index 0000000000..4db3cad4e4 --- /dev/null +++ b/apps/backend/internal/github/app_client_test.go @@ -0,0 +1,285 @@ +package github + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" +) + +type appRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f appRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func testAppPrivateKey(t *testing.T) (*rsa.PrivateKey, []byte) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + return key, pemBytes +} + +func decodeJWTPart(t *testing.T, part string, out any) { + t.Helper() + b, err := base64.RawURLEncoding.DecodeString(part) + if err != nil { + t.Fatalf("decode JWT part: %v", err) + } + if err := json.Unmarshal(b, out); err != nil { + t.Fatalf("unmarshal JWT part: %v", err) + } +} + +func TestAppClient_JWTClaimsAndSignature(t *testing.T) { + key, pemBytes := testAppPrivateKey(t) + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + client, err := NewAppClient(12345, pemBytes) + if err != nil { + t.Fatalf("NewAppClient: %v", err) + } + client.now = func() time.Time { return now } + + token, err := client.AppJWT() + if err != nil { + t.Fatalf("AppJWT: %v", err) + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("JWT parts = %d, want 3", len(parts)) + } + var header map[string]any + decodeJWTPart(t, parts[0], &header) + if header["alg"] != "RS256" || header["typ"] != "JWT" { + t.Fatalf("JWT header = %+v", header) + } + var claims map[string]any + decodeJWTPart(t, parts[1], &claims) + if claims["iss"] != "12345" { + t.Fatalf("iss = %#v, want string app id", claims["iss"]) + } + if got := int64(claims["iat"].(float64)); got != now.Add(-time.Minute).Unix() { + t.Fatalf("iat = %d", got) + } + if got := int64(claims["exp"].(float64)); got != now.Add(9*time.Minute).Unix() { + t.Fatalf("exp = %d", got) + } + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + t.Fatalf("decode signature: %v", err) + } + if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], signature); err != nil { + t.Fatalf("verify JWT: %v", err) + } +} + +func TestAppClient_MintInstallationTokenScopesPermissions(t *testing.T) { + _, pemBytes := testAppPrivateKey(t) + expires := time.Now().UTC().Add(time.Hour).Truncate(time.Second) + var requestBody map[string]any + transport := appRoundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/app/installations/77/access_tokens" || r.Method != http.MethodPost { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") { + t.Fatalf("Authorization = %q", got) + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &requestBody); err != nil { + t.Fatalf("decode request: %v", err) + } + responseBody := `{"token":"ghs_install","expires_at":"` + expires.Format(time.RFC3339) + `","permissions":{"contents":"write","pull_requests":"write"}}` + return &http.Response{ + StatusCode: http.StatusCreated, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }) + + client, err := NewAppClient(123, pemBytes) + if err != nil { + t.Fatalf("NewAppClient: %v", err) + } + client.baseURL = "https://api.test" + client.httpClient.Transport = transport + permissions := InstallationPermissions{"contents": PermissionWrite, "pull_requests": PermissionWrite} + token, err := client.MintInstallationToken(context.Background(), 77, permissions, []string{"widgets"}) + if err != nil { + t.Fatalf("MintInstallationToken: %v", err) + } + if token.Token != "ghs_install" || !token.ExpiresAt.Equal(expires) { + t.Fatalf("token = %+v", token) + } + gotPermissions, ok := requestBody["permissions"].(map[string]any) + if !ok || gotPermissions["contents"] != "write" || gotPermissions["pull_requests"] != "write" { + t.Fatalf("request permissions = %#v", requestBody["permissions"]) + } + if token.Principal.Kind != TokenCredentialInstallation || token.Principal.InstallationID != 77 { + t.Fatalf("principal = %+v", token.Principal) + } + gotRepositories, ok := requestBody["repositories"].([]any) + if !ok || len(gotRepositories) != 1 || gotRepositories[0] != "widgets" { + t.Fatalf("request repositories = %#v", requestBody["repositories"]) + } +} + +func TestAppClient_GetInstallation(t *testing.T) { + _, pemBytes := testAppPrivateKey(t) + transport := appRoundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/app/installations/77" || r.Method != http.MethodGet { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"id":77,"account":{"id":9,"login":"acme","type":"Organization"},` + + `"permissions":{"contents":"write"},"suspended_at":null}`, + )), + }, nil + }) + client, err := NewAppClient(123, pemBytes) + if err != nil { + t.Fatalf("NewAppClient: %v", err) + } + client.baseURL = "https://api.test" + client.httpClient.Transport = transport + installation, err := client.GetInstallation(context.Background(), 77) + if err != nil { + t.Fatalf("GetInstallation: %v", err) + } + if installation.ID != 77 || installation.AccountID != 9 || + installation.AccountLogin != "acme" || installation.AccountType != "Organization" || + installation.Permissions["contents"] != PermissionWrite { + t.Fatalf("installation = %+v", installation) + } +} + +func TestAppClient_GetAuthenticatedApp(t *testing.T) { + _, pemBytes := testAppPrivateKey(t) + transport := appRoundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/app" || r.Method != http.MethodGet { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") { + t.Fatalf("Authorization = %q", got) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{ + "id":123,"client_id":"Iv1.client","slug":"kandev-acme","name":"Kandev Acme", + "owner":{"login":"acme","type":"Organization"}, + "external_url":"https://kandev.example", + "permissions":{"contents":"write"},"events":["installation"] + }`)), + }, nil + }) + client, err := NewAppClient(123, pemBytes) + if err != nil { + t.Fatal(err) + } + client.baseURL = "https://api.test" + client.httpClient.Transport = transport + app, err := client.GetAuthenticatedApp(context.Background()) + if err != nil { + t.Fatal(err) + } + if app.ID != 123 || app.ClientID != "Iv1.client" || app.Slug != "kandev-acme" || + app.OwnerLogin != "acme" || app.OwnerType != "Organization" || + app.Permissions["contents"] != "write" || len(app.Events) != 1 { + t.Fatalf("authenticated App = %+v", app) + } +} + +func TestAppClient_GetAuthenticatedAppBoundsProviderResponse(t *testing.T) { + _, pemBytes := testAppPrivateKey(t) + client, err := NewAppClient(123, pemBytes) + if err != nil { + t.Fatal(err) + } + client.baseURL = "https://api.test" + client.httpClient.Transport = appRoundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + strings.Repeat("x", maxGitHubAppResponseSize+1), + )), + }, nil + }) + _, err = client.GetAuthenticatedApp(context.Background()) + if !errors.Is(err, ErrGitHubAppResponseTooLarge) { + t.Fatalf("oversized authenticated App error = %v, want %v", err, ErrGitHubAppResponseTooLarge) + } +} + +func TestAppClient_GetWebhookConfig(t *testing.T) { + _, pemBytes := testAppPrivateKey(t) + client, err := NewAppClient(123, pemBytes) + if err != nil { + t.Fatal(err) + } + client.baseURL = "https://api.test" + client.httpClient.Transport = appRoundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodGet || r.URL.Path != "/app/hook/config" { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + return &http.Response{ + StatusCode: http.StatusOK, Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"url":"https://kandev.example/webhook","content_type":"json","insecure_ssl":0}`, + )), + }, nil + }) + config, err := client.GetWebhookConfig(context.Background()) + if err != nil { + t.Fatal(err) + } + if config.URL != "https://kandev.example/webhook" || + config.ContentType != "json" || config.InsecureSSL != "0" { + t.Fatalf("webhook config = %+v", config) + } +} + +func TestAppClient_MapsInstallationPermissionsToCapabilities(t *testing.T) { + permissions := InstallationPermissions{ + "metadata": PermissionRead, + "contents": PermissionWrite, + "pull_requests": PermissionRead, + "issues": PermissionWrite, + "checks": PermissionRead, + "administration": PermissionRead, + } + capabilities := CapabilitiesForPermissions(permissions) + for _, capability := range []GitHubAppCapability{ + CapabilityRepositoryRead, + CapabilityGitWrite, + CapabilityPullRequestRead, + CapabilityIssueWrite, + CapabilityChecksRead, + CapabilityBranchProtectionRead, + } { + if !capabilities[capability] { + t.Errorf("capability %q = false", capability) + } + } + if capabilities[CapabilityPullRequestWrite] { + t.Error("read-only pull_requests unexpectedly grants write") + } +} diff --git a/apps/backend/internal/github/app_credential_provider.go b/apps/backend/internal/github/app_credential_provider.go new file mode 100644 index 0000000000..83755f1e16 --- /dev/null +++ b/apps/backend/internal/github/app_credential_provider.go @@ -0,0 +1,57 @@ +package github + +import ( + "context" + "errors" +) + +// CachedInstallationCredentialProvider resolves short-lived installation +// tokens without exposing App private-key material to callers or executors. +type CachedInstallationCredentialProvider struct { + tokens *InstallationTokenCache +} + +func NewCachedInstallationCredentialProvider(tokens *InstallationTokenCache) *CachedInstallationCredentialProvider { + return &CachedInstallationCredentialProvider{tokens: tokens} +} + +func (p *CachedInstallationCredentialProvider) ResolveInstallation( + ctx context.Context, + connection *WorkspaceConnection, + req ResolveCredentialRequest, +) (*ResolvedCredential, error) { + if p == nil || p.tokens == nil { + return nil, ErrGitHubNotConfigured + } + if connection == nil || connection.InstallationID == nil || *connection.InstallationID <= 0 { + return nil, errors.New("GitHub App installation connection is missing an installation ID") + } + if p.tokens.registrationID != "" && connection.AppRegistrationID != p.tokens.registrationID { + return nil, ErrGitHubNotConfigured + } + var repositories []string + if req.RepoName != "" { + repositories = []string{req.RepoName} + } + token, err := p.tokens.GetForWorkspace( + ctx, connection.WorkspaceID, *connection.InstallationID, nil, repositories, + ) + if err != nil { + return nil, err + } + tracker := NewRateTracker(nil, nil) + client := NewTokenClient(token.Token, token.Principal).WithRateTracker(tracker) + return &ResolvedCredential{ + Client: client, + Principal: AuthPrincipal{ + Kind: AuthPrincipalApp, Source: ConnectionSourceGitHubAppInstallation, + Login: connection.InstallationAccountLogin, InstallationID: *connection.InstallationID, + AppRegistrationID: p.tokens.registrationID, + AppCredentialGeneration: p.tokens.credentialGeneration, + }, + Capabilities: CapabilitiesForPermissions(token.Permissions), ExpiresAt: token.ExpiresAt, + AppRegistrationID: p.tokens.registrationID, + AppCredentialGeneration: p.tokens.credentialGeneration, + RateTracker: tracker, credential: token.Token, + }, nil +} diff --git a/apps/backend/internal/github/app_credential_provider_test.go b/apps/backend/internal/github/app_credential_provider_test.go new file mode 100644 index 0000000000..d1c285c18a --- /dev/null +++ b/apps/backend/internal/github/app_credential_provider_test.go @@ -0,0 +1,98 @@ +package github + +import ( + "context" + "errors" + "testing" + "time" +) + +type fixedInstallationMinter struct { + token InstallationToken + repositories *[]string +} + +func (m fixedInstallationMinter) MintInstallationToken( + _ context.Context, + _ int64, + _ InstallationPermissions, + repositories []string, +) (InstallationToken, error) { + if m.repositories != nil { + *m.repositories = append([]string(nil), repositories...) + } + return m.token, nil +} + +func TestCachedInstallationCredentialProviderPreservesActorCapabilitiesAndExpiry(t *testing.T) { + installationID := int64(42) + expiresAt := time.Now().Add(time.Hour) + var repositories []string + provider := NewCachedInstallationCredentialProvider(NewInstallationTokenCache(fixedInstallationMinter{ + repositories: &repositories, + token: InstallationToken{ + Token: "installation-token", + ExpiresAt: expiresAt, + Permissions: InstallationPermissions{ + "contents": PermissionWrite, + "pull_requests": PermissionRead, + }, + Principal: TokenPrincipal{ + Kind: TokenCredentialInstallation, + PrincipalID: "installation:42", + InstallationID: installationID, + }, + }, + })) + + resolved, err := provider.ResolveInstallation(context.Background(), &WorkspaceConnection{ + Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, + InstallationAccountLogin: "acme", + }, ResolveCredentialRequest{ + WorkspaceID: "workspace-1", Purpose: CredentialPurposeAutomation, RepoName: "widgets", + }) + if err != nil { + t.Fatalf("ResolveInstallation: %v", err) + } + if resolved.Principal.Kind != AuthPrincipalApp || resolved.Principal.Login != "acme" { + t.Fatalf("principal = %+v", resolved.Principal) + } + if !resolved.Capabilities[CapabilityGitWrite] || resolved.Capabilities[CapabilityPullRequestWrite] { + t.Fatalf("capabilities = %#v", resolved.Capabilities) + } + if !resolved.ExpiresAt.Equal(expiresAt) { + t.Fatalf("expiry = %v, want %v", resolved.ExpiresAt, expiresAt) + } + if len(repositories) != 1 || repositories[0] != "widgets" { + t.Fatalf("mint repositories = %v", repositories) + } + client, ok := resolved.Client.(*TokenClient) + if !ok || client.Principal().InstallationID != installationID { + t.Fatalf("client principal = %+v", client) + } +} + +func TestCachedInstallationCredentialProviderRequiresInstallationID(t *testing.T) { + provider := NewCachedInstallationCredentialProvider(NewInstallationTokenCache(fixedInstallationMinter{})) + if _, err := provider.ResolveInstallation( + context.Background(), + &WorkspaceConnection{}, + ResolveCredentialRequest{}, + ); err == nil { + t.Fatal("expected missing installation ID error") + } +} + +func TestCachedInstallationCredentialProviderRejectsDifferentRegistration(t *testing.T) { + installationID := int64(42) + cache := NewAppInstallationTokenCache("registration-a", 5, fixedInstallationMinter{}) + provider := NewCachedInstallationCredentialProvider(cache) + _, err := provider.ResolveInstallation(context.Background(), &WorkspaceConnection{ + Source: ConnectionSourceGitHubAppInstallation, InstallationID: &installationID, + AppRegistrationID: "registration-b", + }, ResolveCredentialRequest{}) + if !errors.Is(err, ErrGitHubNotConfigured) { + t.Fatalf("error = %v, want ErrGitHubNotConfigured", err) + } +} diff --git a/apps/backend/internal/github/app_installation_service.go b/apps/backend/internal/github/app_installation_service.go new file mode 100644 index 0000000000..fefaea16a0 --- /dev/null +++ b/apps/backend/internal/github/app_installation_service.go @@ -0,0 +1,265 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +var ErrInstallationAssociationUnverified = errors.New("GitHub App installation association could not be verified") + +type GitHubOAuthTokens struct { + AccessToken string + RefreshToken string + AccessExpiresAt time.Time + RefreshExpiresAt *time.Time +} + +type GitHubOAuthUser struct { + ID int64 `json:"id"` + Login string `json:"login"` +} + +type githubAppOAuth interface { + ExchangeUserCode(ctx context.Context, code, pkceVerifier, redirectURI string) (GitHubOAuthTokens, error) + GetOAuthUser(ctx context.Context, accessToken string) (GitHubOAuthUser, error) + UserCanAccessInstallation(ctx context.Context, accessToken string, installationID int64) (bool, error) +} + +type appInstallationVerifier interface { + GetInstallation(context.Context, int64) (AppInstallation, error) +} + +type appInstallationStore interface { + GetWorkspaceConnection(context.Context, string) (*WorkspaceConnection, error) + ReplaceWorkspaceConnection( + context.Context, + *WorkspaceConnection, + WorkspaceConnectionExpectation, + ) error +} + +type AppInstallationConfig struct { + RegistrationID string + Slug string + CallbackURL string +} + +type AppInstallationStart struct { + URL string `json:"url"` + State string `json:"state"` + ExpiresAt time.Time `json:"expires_at"` +} + +type AppInstallationCallback struct { + WorkspaceID string + UserID string + State string + Code string + SetupAction string + InstallationID int64 +} + +type AppInstallationResult struct { + WorkspaceID string + Installation AppInstallation + AuthorizingUser GitHubOAuthUser +} + +type AppInstallationService struct { + config AppInstallationConfig + flows *OAuthFlowManager + store appInstallationStore + app appInstallationVerifier + oauth githubAppOAuth +} + +func NewAppInstallationService( + config AppInstallationConfig, + flows *OAuthFlowManager, + store appInstallationStore, + app appInstallationVerifier, + oauth githubAppOAuth, +) *AppInstallationService { + return &AppInstallationService{config: config, flows: flows, store: store, app: app, oauth: oauth} +} + +func (s *AppInstallationService) Start( + ctx context.Context, + workspaceID, userID string, +) (AppInstallationStart, error) { + if s == nil || s.flows == nil || s.store == nil { + return AppInstallationStart{}, errors.New("GitHub App installation service is not configured") + } + if err := validateAppInstallationConfig(s.config); err != nil { + return AppInstallationStart{}, err + } + existing, err := s.store.GetWorkspaceConnection(ctx, workspaceID) + if err != nil { + return AppInstallationStart{}, fmt.Errorf("load GitHub workspace connection: %w", err) + } + expected := workspaceConnectionExpectation(existing) + flow, err := s.flows.Start(ctx, OAuthFlowRequest{ + WorkspaceID: workspaceID, + UserID: userID, + AppRegistrationID: s.config.RegistrationID, + Kind: AuthFlowKindAppInstallation, + ExpectedWorkspaceSource: expected.Source, + ExpectedWorkspaceGeneration: expected.CredentialGeneration, + ExpectedInstallationID: expected.InstallationID, + ExpectedWorkspaceAppRegistrationID: expected.AppRegistrationID, + }) + if err != nil { + return AppInstallationStart{}, err + } + installURL := &url.URL{ + Scheme: "https", + Host: "github.com", + Path: "/apps/" + s.config.Slug + "/installations/new", + } + query := installURL.Query() + query.Set("state", flow.State) + installURL.RawQuery = query.Encode() + return AppInstallationStart{URL: installURL.String(), State: flow.State, ExpiresAt: flow.ExpiresAt}, nil +} + +func (s *AppInstallationService) Complete( + ctx context.Context, + callback AppInstallationCallback, +) (AppInstallationResult, error) { + if s == nil || s.flows == nil || s.store == nil || s.app == nil || s.oauth == nil { + return AppInstallationResult{}, errors.New("GitHub App installation service is not configured") + } + flow, err := consumeCallbackFlow( + ctx, s.flows, callback.State, callback.WorkspaceID, callback.UserID, + s.config.RegistrationID, AuthFlowKindAppInstallation, + ) + if err != nil { + return AppInstallationResult{}, err + } + installation, user, err := s.verifyInstallationCallback(ctx, callback, flow) + if err != nil { + return AppInstallationResult{}, err + } + connection := s.newInstallationConnection(flow, installation) + if err := s.store.ReplaceWorkspaceConnection( + ctx, connection, authFlowWorkspaceExpectation(flow), + ); err != nil { + return AppInstallationResult{}, fmt.Errorf("save GitHub App installation: %w", err) + } + return AppInstallationResult{ + WorkspaceID: flow.WorkspaceID, Installation: installation, AuthorizingUser: user, + }, nil +} + +func (s *AppInstallationService) verifyInstallationCallback( + ctx context.Context, + callback AppInstallationCallback, + flow *AuthFlow, +) (AppInstallation, GitHubOAuthUser, error) { + if callback.InstallationID <= 0 || callback.Code == "" || + (callback.SetupAction != "install" && callback.SetupAction != "update") { + return AppInstallation{}, GitHubOAuthUser{}, ErrInstallationAssociationUnverified + } + + tokens, err := s.oauth.ExchangeUserCode(ctx, callback.Code, flow.PKCEVerifier, s.config.CallbackURL) + if err != nil || tokens.AccessToken == "" { + return AppInstallation{}, GitHubOAuthUser{}, fmt.Errorf( + "exchange GitHub App authorizer code: %w", associationError(err), + ) + } + user, err := s.oauth.GetOAuthUser(ctx, tokens.AccessToken) + if err != nil || user.ID <= 0 || strings.TrimSpace(user.Login) == "" { + return AppInstallation{}, GitHubOAuthUser{}, fmt.Errorf( + "verify GitHub App authorizing user: %w", associationError(err), + ) + } + accessible, err := s.oauth.UserCanAccessInstallation(ctx, tokens.AccessToken, callback.InstallationID) + if err != nil || !accessible { + return AppInstallation{}, GitHubOAuthUser{}, fmt.Errorf( + "verify GitHub App installation access: %w", associationError(err), + ) + } + installation, err := s.app.GetInstallation(ctx, callback.InstallationID) + if err != nil || installation.ID != callback.InstallationID { + return AppInstallation{}, GitHubOAuthUser{}, fmt.Errorf( + "verify GitHub App installation: %w", associationError(err), + ) + } + return installation, user, nil +} + +func (s *AppInstallationService) newInstallationConnection( + flow *AuthFlow, + installation AppInstallation, +) *WorkspaceConnection { + status := ConnectionStatusActive + if installation.SuspendedAt != nil { + status = ConnectionStatusSuspended + } + return &WorkspaceConnection{ + WorkspaceID: flow.WorkspaceID, + Source: ConnectionSourceGitHubAppInstallation, + GitHubHost: "github.com", + InstallationID: &installation.ID, + InstallationAccountLogin: installation.AccountLogin, + InstallationAccountType: installation.AccountType, + AppRegistrationID: flow.AppRegistrationID, + Status: status, + CredentialGeneration: flow.ExpectedWorkspaceGeneration + 1, + } +} + +func validateAppInstallationConfig(config AppInstallationConfig) error { + if strings.TrimSpace(config.RegistrationID) == "" { + return errors.New("GitHub App registration ID is required") + } + if !validGitHubAppSlug(config.Slug) { + return errors.New("GitHub App slug is required") + } + callback, err := url.Parse(config.CallbackURL) + if err != nil || !callback.IsAbs() || callback.Host == "" { + return errors.New("GitHub App callback URL must be absolute") + } + return nil +} + +func validGitHubAppSlug(slug string) bool { + if slug == "" { + return false + } + for _, character := range slug { + if (character < 'a' || character > 'z') && (character < '0' || character > '9') && character != '-' { + return false + } + } + return true +} + +func associationError(err error) error { + if err == nil { + return ErrInstallationAssociationUnverified + } + return fmt.Errorf("%w: %v", ErrInstallationAssociationUnverified, err) +} + +func consumeCallbackFlow( + ctx context.Context, + flows *OAuthFlowManager, + state, workspaceID, userID string, + registrationID string, + kind AuthFlowKind, +) (*AuthFlow, error) { + if workspaceID == "" && userID == "" { + return flows.ConsumeBound(ctx, state, registrationID, kind) + } + return flows.Consume(ctx, state, OAuthFlowExpectation{ + WorkspaceID: workspaceID, + UserID: userID, + AppRegistrationID: registrationID, + Kind: kind, + }) +} diff --git a/apps/backend/internal/github/app_installation_service_test.go b/apps/backend/internal/github/app_installation_service_test.go new file mode 100644 index 0000000000..2ac85523ae --- /dev/null +++ b/apps/backend/internal/github/app_installation_service_test.go @@ -0,0 +1,241 @@ +package github + +import ( + "context" + "errors" + "net/url" + "strings" + "testing" +) + +type appInstallationMemoryStore struct { + connection *WorkspaceConnection +} + +func (s *appInstallationMemoryStore) GetWorkspaceConnection(_ context.Context, workspaceID string) (*WorkspaceConnection, error) { + if s.connection == nil || s.connection.WorkspaceID != workspaceID { + return nil, nil + } + copy := *s.connection + return ©, nil +} + +func (s *appInstallationMemoryStore) ReplaceWorkspaceConnection( + _ context.Context, + connection *WorkspaceConnection, + expected WorkspaceConnectionExpectation, +) error { + if !matchesWorkspaceConnectionExpectation(s.connection, expected) { + return ErrOAuthFlowStale + } + copy := *connection + if s.connection != nil { + copy.CreatedAt = s.connection.CreatedAt + } + s.connection = © + return nil +} + +type fakeAppInstallationVerifier struct { + installation AppInstallation + err error +} + +func (f *fakeAppInstallationVerifier) GetInstallation(_ context.Context, _ int64) (AppInstallation, error) { + return f.installation, f.err +} + +type fakeGitHubAppOAuth struct { + tokens GitHubOAuthTokens + user GitHubOAuthUser + userErr error + canAccess bool + exchangeCode string + verifier string + exchangeStarted chan struct{} + releaseExchange chan struct{} +} + +func (f *fakeGitHubAppOAuth) ExchangeUserCode( + ctx context.Context, code, verifier, _ string, +) (GitHubOAuthTokens, error) { + f.exchangeCode = code + f.verifier = verifier + if f.exchangeStarted != nil { + select { + case f.exchangeStarted <- struct{}{}: + default: + } + } + if f.releaseExchange != nil { + select { + case <-f.releaseExchange: + case <-ctx.Done(): + return GitHubOAuthTokens{}, ctx.Err() + } + } + return f.tokens, nil +} + +func (f *fakeGitHubAppOAuth) GetOAuthUser(_ context.Context, _ string) (GitHubOAuthUser, error) { + return f.user, f.userErr +} + +func (f *fakeGitHubAppOAuth) UserCanAccessInstallation(_ context.Context, _ string, _ int64) (bool, error) { + return f.canAccess, nil +} + +func TestAppInstallationStartBuildsStateBoundInstallURL(t *testing.T) { + flowStore := &oauthFlowMemoryStore{} + flows := NewOAuthFlowManager(flowStore) + flows.random = strings.NewReader(strings.Repeat("d", oauthRandomBytes)) + service := NewAppInstallationService( + AppInstallationConfig{RegistrationID: "registration-test", Slug: "kandev-app", CallbackURL: "https://kandev.example/api/v1/github/app/install/callback"}, + flows, &appInstallationMemoryStore{}, &fakeAppInstallationVerifier{}, &fakeGitHubAppOAuth{}, + ) + + started, err := service.Start(context.Background(), "workspace-1", "user-1") + if err != nil { + t.Fatalf("Start() error = %v", err) + } + parsed, err := url.Parse(started.URL) + if err != nil { + t.Fatalf("parse URL: %v", err) + } + if parsed.Host != "github.com" || parsed.Path != "/apps/kandev-app/installations/new" { + t.Fatalf("install URL = %q", started.URL) + } + if parsed.Query().Get("state") == "" || parsed.Query().Has("redirect_uri") { + t.Fatalf("install URL query = %v, want only supported state binding", parsed.Query()) + } +} + +func TestAppInstallationCompleteAcceptsOAuthDuringInstallCallbackShape(t *testing.T) { + flowStore := &oauthFlowMemoryStore{} + flows := NewOAuthFlowManager(flowStore) + flows.random = strings.NewReader(strings.Repeat("e", oauthRandomBytes)) + store := &appInstallationMemoryStore{connection: &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourcePAT, Status: ConnectionStatusActive, + CredentialGeneration: 3, + }} + verifier := &fakeAppInstallationVerifier{installation: AppInstallation{ + ID: 42, AccountID: 7, AccountLogin: "acme", AccountType: "Organization", + Permissions: InstallationPermissions{"contents": PermissionWrite}, + }} + oauth := &fakeGitHubAppOAuth{ + tokens: GitHubOAuthTokens{AccessToken: "temporary-user-token"}, + user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true, + } + service := NewAppInstallationService( + AppInstallationConfig{RegistrationID: "registration-test", Slug: "kandev-app", CallbackURL: "https://kandev.example/callback"}, + flows, store, verifier, oauth, + ) + started, err := service.Start(context.Background(), "workspace-1", "user-1") + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + result, err := service.Complete(context.Background(), AppInstallationCallback{ + State: started.State, Code: "oauth-code", SetupAction: "install", InstallationID: 42, + }) + if err != nil { + t.Fatalf("Complete() error = %v", err) + } + if result.AuthorizingUser.Login != "octocat" || result.Installation.AccountLogin != "acme" { + t.Fatalf("Complete() = %+v", result) + } + if store.connection.Source != ConnectionSourceGitHubAppInstallation || + store.connection.CredentialGeneration != 4 || store.connection.InstallationID == nil || + *store.connection.InstallationID != 42 { + t.Fatalf("persisted connection = %+v", store.connection) + } +} + +func TestAppInstallationCompleteRejectsSpoofedAssociation(t *testing.T) { + flowStore := &oauthFlowMemoryStore{} + flows := NewOAuthFlowManager(flowStore) + flows.random = strings.NewReader(strings.Repeat("f", oauthRandomBytes)) + store := &appInstallationMemoryStore{} + service := NewAppInstallationService( + AppInstallationConfig{RegistrationID: "registration-test", Slug: "kandev-app", CallbackURL: "https://kandev.example/callback"}, + flows, + store, + &fakeAppInstallationVerifier{installation: AppInstallation{ID: 42}}, + &fakeGitHubAppOAuth{tokens: GitHubOAuthTokens{AccessToken: "token"}, user: GitHubOAuthUser{ID: 11}, canAccess: false}, + ) + started, err := service.Start(context.Background(), "workspace-1", "user-1") + if err != nil { + t.Fatalf("Start() error = %v", err) + } + _, err = service.Complete(context.Background(), AppInstallationCallback{ + WorkspaceID: "workspace-1", UserID: "user-1", State: started.State, + Code: "oauth-code", SetupAction: "install", InstallationID: 42, + }) + if !errors.Is(err, ErrInstallationAssociationUnverified) { + t.Fatalf("Complete() error = %v, want %v", err, ErrInstallationAssociationUnverified) + } + if store.connection != nil { + t.Fatalf("spoofed callback persisted %+v", store.connection) + } +} + +func TestAppInstallationCompleteRejectsSetupURLShapeWithoutOAuthCode(t *testing.T) { + flowStore := &oauthFlowMemoryStore{} + flows := NewOAuthFlowManager(flowStore) + flows.random = strings.NewReader(strings.Repeat("g", oauthRandomBytes)) + service := NewAppInstallationService( + AppInstallationConfig{RegistrationID: "registration-test", Slug: "kandev-app", CallbackURL: "https://kandev.example/callback"}, + flows, + &appInstallationMemoryStore{}, + &fakeAppInstallationVerifier{installation: AppInstallation{ID: 42}}, + &fakeGitHubAppOAuth{}, + ) + started, err := service.Start(context.Background(), "workspace-1", "user-1") + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + _, err = service.Complete(context.Background(), AppInstallationCallback{ + State: started.State, SetupAction: "install", InstallationID: 42, + }) + if !errors.Is(err, ErrInstallationAssociationUnverified) { + t.Fatalf("Complete() error = %v, want setup-only callback rejection", err) + } +} + +func TestAppInstallationCompleteCannotOverwriteNewerPAT(t *testing.T) { + flowStore := &oauthFlowMemoryStore{} + flows := NewOAuthFlowManager(flowStore) + flows.random = strings.NewReader(strings.Repeat("h", oauthRandomBytes)) + store := &appInstallationMemoryStore{connection: &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourcePAT, Login: "old", + Status: ConnectionStatusActive, CredentialGeneration: 1, + }} + service := NewAppInstallationService( + AppInstallationConfig{RegistrationID: "registration-test", Slug: "kandev-app", CallbackURL: "https://kandev.example/callback"}, + flows, store, + &fakeAppInstallationVerifier{installation: AppInstallation{ + ID: 42, AccountLogin: "acme", AccountType: "Organization", + }}, + &fakeGitHubAppOAuth{ + tokens: GitHubOAuthTokens{AccessToken: "temporary"}, + user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true, + }, + ) + started, err := service.Start(context.Background(), "workspace-1", "user-1") + if err != nil { + t.Fatal(err) + } + store.connection = &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourcePAT, Login: "new", + Status: ConnectionStatusActive, CredentialGeneration: 2, + } + + _, err = service.Complete(context.Background(), AppInstallationCallback{ + State: started.State, Code: "oauth-code", SetupAction: "install", InstallationID: 42, + }) + if !errors.Is(err, ErrOAuthFlowStale) || store.connection.Source != ConnectionSourcePAT || + store.connection.Login != "new" || store.connection.CredentialGeneration != 2 { + t.Fatalf("Complete() error = %v, connection = %+v", err, store.connection) + } +} diff --git a/apps/backend/internal/github/app_registration_helpers.go b/apps/backend/internal/github/app_registration_helpers.go new file mode 100644 index 0000000000..796b39822a --- /dev/null +++ b/apps/backend/internal/github/app_registration_helpers.go @@ -0,0 +1,122 @@ +package github + +import ( + "context" + "errors" + "slices" + "strings" + + "github.com/kandev/kandev/internal/auth/authn" +) + +var ( + ErrDeploymentAppOperatorRequired = errors.New("deployment GitHub App operator access is required") + ErrDeploymentAppIdentityMismatch = errors.New("GitHub App manifest result did not match the requested owner") + ErrDeploymentAppPolicyMismatch = errors.New("GitHub App manifest result did not match the required policy") + ErrDeploymentAppRegistrationCancelled = errors.New("GitHub App registration was cancelled") +) + +const defaultGitHubAppHost = "github.com" + +type DeploymentAppManifestConverter interface { + Convert(context.Context, string) (ManifestConversionResult, error) +} + +func requireDeploymentAppOperator(ctx context.Context, userID string) error { + identity, ok := authn.IdentityFromContext(ctx) + if !ok { + // Lifecycle jobs and legacy direct callers are internal. HTTP and WS + // requests always carry an identity (synthetic when auth is disabled). + return nil + } + if !identity.IsAdmin() || identity.UserID != userID { + return ErrDeploymentAppOperatorRequired + } + return nil +} + +func deploymentOwnerType(ownerType ManifestOwnerType) DeploymentAppOwnerType { + if ownerType == ManifestOwnerOrganization { + return DeploymentAppOwnerOrganization + } + return DeploymentAppOwnerUser +} + +func deploymentOwnerTypeFromGitHub(ownerType string) DeploymentAppOwnerType { + if strings.EqualFold(ownerType, string(DeploymentAppOwnerOrganization)) { + return DeploymentAppOwnerOrganization + } + return DeploymentAppOwnerUser +} + +func matchesDeploymentAppOwner( + flow *DeploymentAppRegistrationFlow, + converted ManifestConversionResult, +) bool { + return flow != nil && strings.EqualFold(flow.OwnerLogin, converted.Owner.Login) && + strings.EqualFold(string(flow.OwnerType), converted.Owner.Type) +} + +func matchesDeploymentAppPolicy(converted ManifestConversionResult) bool { + submission, err := BuildDeploymentAppManifest( + ManifestOwnerUser, "policy", "https://kandev.example", + ) + if err != nil { + return false + } + if len(converted.Permissions) != len(submission.Manifest.DefaultPermissions) || + len(converted.Events) != len(submission.Manifest.DefaultEvents) { + return false + } + for permission, level := range submission.Manifest.DefaultPermissions { + if converted.Permissions[permission] != level { + return false + } + } + for _, event := range submission.Manifest.DefaultEvents { + if !slices.Contains(converted.Events, event) { + return false + } + } + return true +} + +func resolvedDeploymentAppRegistration( + registration *DeploymentAppRegistration, + credentials DeploymentAppCredentials, +) ResolvedDeploymentAppConfig { + return ResolvedDeploymentAppConfig{ + Source: DeploymentAppSourceManaged, Registration: registration, + Config: appRegistrationRuntimeConfig(registration, credentials), + } +} + +func cloneDeploymentAppRegistration(value *DeploymentAppRegistration) *DeploymentAppRegistration { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func sanitizeDeploymentAppActivationError(_ error) error { + return errors.New("generated GitHub App credentials could not be activated") +} + +func sanitizeDeploymentAppActivationAndCleanupError(activationErr, cleanupErr error) error { + activationErr = sanitizeDeploymentAppActivationError(activationErr) + if cleanupErr == nil { + return activationErr + } + return errors.Join( + activationErr, + errors.New("generated GitHub App credentials could not be cleaned up; retry deletion"), + ) +} + +func sanitizeDeploymentAppPersistenceError(err error) error { + if errors.Is(err, ErrDeploymentAppInUse) { + return ErrDeploymentAppInUse + } + return errors.New("generated GitHub App credentials could not be stored") +} diff --git a/apps/backend/internal/github/app_registration_import.go b/apps/backend/internal/github/app_registration_import.go new file mode 100644 index 0000000000..acb04d020d --- /dev/null +++ b/apps/backend/internal/github/app_registration_import.go @@ -0,0 +1,326 @@ +package github + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + maxAppRegistrationDisplayName = 100 + maxAppRegistrationWorkspaceID = 255 + maxAppRegistrationClientID = 255 + maxAppRegistrationClientSecret = 4096 + maxAppRegistrationPrivateKey = 64 * 1024 + maxAppRegistrationWebhookSecret = 4096 +) + +type AppRegistrationImportErrorCode string + +const ( + AppRegistrationImportInvalidRequest AppRegistrationImportErrorCode = "invalid_request" + AppRegistrationImportVerificationFailed AppRegistrationImportErrorCode = "verification_failed" + AppRegistrationImportIdentityMismatch AppRegistrationImportErrorCode = "identity_mismatch" + AppRegistrationImportPolicyMismatch AppRegistrationImportErrorCode = "policy_mismatch" + AppRegistrationImportAlreadyRegistered AppRegistrationImportErrorCode = "already_registered" + AppRegistrationImportPersistenceFailed AppRegistrationImportErrorCode = "persistence_failed" +) + +type AppRegistrationImportError struct { + Code AppRegistrationImportErrorCode + ExistingRegistrationID string + Problems []string +} + +func (e *AppRegistrationImportError) Error() string { + if e == nil { + return "GitHub App import failed" + } + return "GitHub App import failed: " + string(e.Code) +} + +type AppRegistrationImportRequest struct { + RegistrationID string `json:"registration_id"` + WorkspaceID string `json:"workspace_id"` + DisplayName string `json:"display_name"` + GitHubHost string `json:"github_host"` + AppID int64 `json:"app_id"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + PrivateKey string `json:"private_key"` + WebhookSecret string `json:"webhook_secret"` + Slug string `json:"slug"` + OwnerLogin string `json:"owner_login"` + OwnerType AppRegistrationOwnerType `json:"owner_type"` + Visibility AppRegistrationVisibility `json:"visibility"` + PublicBaseURL string `json:"public_base_url"` +} + +type AppRegistrationVerifier interface { + GetAuthenticatedApp(context.Context) (AuthenticatedApp, error) +} + +type AppRegistrationWebhookVerifier interface { + GetWebhookConfig(context.Context) (AppWebhookConfig, error) +} + +type AppRegistrationVerifierFactory func(int64, []byte) (AppRegistrationVerifier, error) + +type AppRegistrationImporter struct { + repository *AppRegistrationRepository + resolver PublicGitHubBaseURLResolver + verifier AppRegistrationVerifierFactory +} + +func NewAppRegistrationImporter( + repository *AppRegistrationRepository, + resolver PublicGitHubBaseURLResolver, + verifier AppRegistrationVerifierFactory, +) *AppRegistrationImporter { + return &AppRegistrationImporter{repository: repository, resolver: resolver, verifier: verifier} +} + +func (s *AppRegistrationImporter) Import( + ctx context.Context, + request AppRegistrationImportRequest, +) (*AppRegistration, error) { + validated, manifest, err := s.validateRequest(ctx, request) + if err != nil { + return nil, err + } + if duplicate, err := s.findDuplicate(ctx, validated); err != nil || duplicate != nil { + return nil, duplicateOrPersistenceError(duplicate, err) + } + verifier, err := s.verifierFactory()(validated.AppID, []byte(validated.PrivateKey)) + if err != nil { + return nil, importError(AppRegistrationImportVerificationFailed) + } + identity, err := verifier.GetAuthenticatedApp(ctx) + if err != nil { + return nil, importError(AppRegistrationImportVerificationFailed) + } + if problems := importedAppIdentityProblems(validated, identity); len(problems) > 0 { + return nil, &AppRegistrationImportError{ + Code: AppRegistrationImportIdentityMismatch, Problems: problems, + } + } + var webhookConfig *AppWebhookConfig + if webhookVerifier, ok := verifier.(AppRegistrationWebhookVerifier); ok { + config, err := webhookVerifier.GetWebhookConfig(ctx) + if err != nil { + return nil, importError(AppRegistrationImportVerificationFailed) + } + webhookConfig = &config + } + if problems := importedAppPolicyProblems(manifest.Manifest, identity, webhookConfig); len(problems) > 0 { + return nil, &AppRegistrationImportError{ + Code: AppRegistrationImportPolicyMismatch, Problems: problems, + } + } + registration := importedAppRegistration(validated, identity) + credentials := DeploymentAppCredentials{ + PrivateKey: validated.PrivateKey, ClientSecret: validated.ClientSecret, + WebhookSecret: validated.WebhookSecret, + } + if err := s.repository.CreateRegistration(ctx, registration, credentials); err != nil { + duplicate, lookupErr := s.findDuplicate(ctx, validated) + if lookupErr == nil && duplicate != nil { + return nil, duplicateImportError(duplicate.ID) + } + return nil, importError(AppRegistrationImportPersistenceFailed) + } + return registration, nil +} + +func (s *AppRegistrationImporter) validateRequest( + ctx context.Context, + request AppRegistrationImportRequest, +) (AppRegistrationImportRequest, DeploymentAppManifestSubmission, error) { + if s == nil || s.repository == nil || s.repository.store == nil || s.repository.secrets == nil { + return request, DeploymentAppManifestSubmission{}, importError(AppRegistrationImportPersistenceFailed) + } + if !validImportScalarFields(request) { + return request, DeploymentAppManifestSubmission{}, importError(AppRegistrationImportInvalidRequest) + } + parsedID, err := uuid.Parse(request.RegistrationID) + if err != nil || parsedID.String() != request.RegistrationID { + return request, DeploymentAppManifestSubmission{}, importError(AppRegistrationImportInvalidRequest) + } + request.PublicBaseURL, err = ValidatePublicGitHubBaseURL(ctx, request.PublicBaseURL, s.resolver) + if err != nil { + return request, DeploymentAppManifestSubmission{}, importError(AppRegistrationImportInvalidRequest) + } + manifest, err := BuildAppRegistrationManifest(AppRegistrationManifestRequest{ + RegistrationID: request.RegistrationID, + OwnerType: importManifestOwnerType(request.OwnerType), OwnerLogin: request.OwnerLogin, + PublicBaseURL: request.PublicBaseURL, Visibility: request.Visibility, + }) + if err != nil { + return request, DeploymentAppManifestSubmission{}, importError(AppRegistrationImportInvalidRequest) + } + return request, manifest, nil +} + +func validImportScalarFields(request AppRegistrationImportRequest) bool { + return request.GitHubHost == defaultGitHubAppHost && request.AppID > 0 && + boundedTrimmed(request.WorkspaceID, maxAppRegistrationWorkspaceID) && + boundedTrimmed(request.DisplayName, maxAppRegistrationDisplayName) && + boundedTrimmed(request.ClientID, maxAppRegistrationClientID) && + boundedSecret(request.ClientSecret, maxAppRegistrationClientSecret) && + boundedSecret(request.PrivateKey, maxAppRegistrationPrivateKey) && + boundedSecret(request.WebhookSecret, maxAppRegistrationWebhookSecret) && + boundedTrimmed(request.Slug, maxAppRegistrationDisplayName) && + validManifestOwner(importManifestOwnerType(request.OwnerType), request.OwnerLogin) && + validImportVisibility(request.Visibility) +} + +func boundedTrimmed(value string, maxLength int) bool { + return value != "" && strings.TrimSpace(value) == value && len(value) <= maxLength +} + +func boundedSecret(value string, maxLength int) bool { + return value != "" && len(value) <= maxLength +} + +func validImportVisibility(visibility AppRegistrationVisibility) bool { + return visibility == "" || visibility == AppRegistrationVisibilityPrivate || + visibility == AppRegistrationVisibilityPublic +} + +func importManifestOwnerType(ownerType AppRegistrationOwnerType) ManifestOwnerType { + if ownerType == AppRegistrationOwnerOrganization { + return ManifestOwnerOrganization + } + if ownerType == AppRegistrationOwnerUser { + return ManifestOwnerUser + } + return "" +} + +func (s *AppRegistrationImporter) findDuplicate( + ctx context.Context, + request AppRegistrationImportRequest, +) (*AppRegistration, error) { + return s.repository.store.GetAppRegistrationByGitHubApp(ctx, request.GitHubHost, request.AppID) +} + +func (s *AppRegistrationImporter) verifierFactory() AppRegistrationVerifierFactory { + if s.verifier != nil { + return s.verifier + } + return func(appID int64, privateKey []byte) (AppRegistrationVerifier, error) { + return NewAppClient(appID, privateKey) + } +} + +func importedAppIdentityProblems( + request AppRegistrationImportRequest, + identity AuthenticatedApp, +) []string { + var problems []string + if identity.ID != request.AppID { + problems = append(problems, "app_id") + } + if identity.ClientID != request.ClientID { + problems = append(problems, "client_id") + } + if !strings.EqualFold(identity.Slug, request.Slug) { + problems = append(problems, "slug") + } + if !strings.EqualFold(identity.OwnerLogin, request.OwnerLogin) { + problems = append(problems, "owner_login") + } + if !strings.EqualFold(identity.OwnerType, string(request.OwnerType)) { + problems = append(problems, "owner_type") + } + return problems +} + +func importedAppPolicyProblems( + manifest DeploymentAppManifest, + identity AuthenticatedApp, + webhookConfig *AppWebhookConfig, +) []string { + var problems []string + if identity.ExternalURL != manifest.URL { + problems = append(problems, "external_url") + } + if webhookConfig != nil && (webhookConfig.URL != manifest.HookAttributes.URL || + webhookConfig.ContentType != "json" || webhookConfig.InsecureSSL != "0") { + problems = append(problems, "webhook") + } + for name, required := range manifest.DefaultPermissions { + if !permissionSatisfies(identity.Permissions[name], required) { + problems = append(problems, "permission:"+name) + } + } + for _, event := range manifest.DefaultEvents { + if !containsString(identity.Events, event) { + problems = append(problems, "event:"+event) + } + } + return problems +} + +func permissionSatisfies(actual, required string) bool { + return actual == required || + (required == string(PermissionRead) && actual == string(PermissionWrite)) +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func importedAppRegistration( + request AppRegistrationImportRequest, + identity AuthenticatedApp, +) *AppRegistration { + now := time.Now().UTC() + visibility := request.Visibility + if visibility == "" { + visibility = AppRegistrationVisibilityPrivate + } + return &AppRegistration{ + ID: request.RegistrationID, Source: AppRegistrationSourceImported, + DisplayName: request.DisplayName, GitHubHost: request.GitHubHost, + AppID: identity.ID, ClientID: identity.ClientID, Slug: identity.Slug, + OwnerLogin: identity.OwnerLogin, OwnerType: AppRegistrationOwnerType(identity.OwnerType), + Visibility: visibility, PublicBaseURL: request.PublicBaseURL, + CreatedForWorkspaceID: request.WorkspaceID, CredentialGeneration: 1, + Status: AppRegistrationStatusActive, WebhookStatus: DeploymentAppWebhookUnverified, + CreatedAt: now, UpdatedAt: now, + } +} + +func duplicateOrPersistenceError(duplicate *AppRegistration, err error) error { + if duplicate != nil { + return duplicateImportError(duplicate.ID) + } + if err != nil { + return importError(AppRegistrationImportPersistenceFailed) + } + return nil +} + +func duplicateImportError(registrationID string) error { + return &AppRegistrationImportError{ + Code: AppRegistrationImportAlreadyRegistered, ExistingRegistrationID: registrationID, + } +} + +func importError(code AppRegistrationImportErrorCode) error { + return &AppRegistrationImportError{Code: code} +} + +func IsAppRegistrationImportError(err error, code AppRegistrationImportErrorCode) bool { + var importErr *AppRegistrationImportError + return errors.As(err, &importErr) && importErr.Code == code +} diff --git a/apps/backend/internal/github/app_registration_import_test.go b/apps/backend/internal/github/app_registration_import_test.go new file mode 100644 index 0000000000..a48c742890 --- /dev/null +++ b/apps/backend/internal/github/app_registration_import_test.go @@ -0,0 +1,240 @@ +package github + +import ( + "context" + "errors" + "net/netip" + "strings" + "testing" + "time" +) + +type appRegistrationVerifierFunc func(context.Context) (AuthenticatedApp, error) + +func (f appRegistrationVerifierFunc) GetAuthenticatedApp(ctx context.Context) (AuthenticatedApp, error) { + return f(ctx) +} + +type appRegistrationVerifierWithWebhook struct { + app AuthenticatedApp + config AppWebhookConfig +} + +func (v appRegistrationVerifierWithWebhook) GetAuthenticatedApp(context.Context) (AuthenticatedApp, error) { + return v.app, nil +} + +func (v appRegistrationVerifierWithWebhook) GetWebhookConfig(context.Context) (AppWebhookConfig, error) { + return v.config, nil +} + +func TestAppRegistrationImportReturnsKnownDuplicateWithoutVerification(t *testing.T) { + store := newTestStore(t) + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + request := validAppRegistrationImportRequest(t) + existing := newAppRegistration("existing-registration", request.AppID, "Existing", time.Now().UTC()) + if err := repository.SaveRegistration(context.Background(), existing, DeploymentAppCredentials{ + PrivateKey: "existing-key", ClientSecret: "existing-client", WebhookSecret: "existing-webhook", + }); err != nil { + t.Fatal(err) + } + importer := NewAppRegistrationImporter(repository, importPublicResolver(), + func(int64, []byte) (AppRegistrationVerifier, error) { + t.Fatal("duplicate import attempted provider verification") + return nil, nil + }) + _, err := importer.Import(context.Background(), request) + var importErr *AppRegistrationImportError + if !errors.As(err, &importErr) || importErr.Code != AppRegistrationImportAlreadyRegistered || + importErr.ExistingRegistrationID != existing.ID { + t.Fatalf("duplicate import error = %#v", err) + } +} + +func TestAppRegistrationImportRejectsIdentityAndPolicyMismatchWithoutSecrets(t *testing.T) { + tests := []struct { + name string + mutate func(*AuthenticatedApp) + wantCode AppRegistrationImportErrorCode + wantProblem string + }{ + { + name: "identity", + mutate: func(app *AuthenticatedApp) { app.OwnerLogin = "different-owner" }, + wantCode: AppRegistrationImportIdentityMismatch, wantProblem: "owner_login", + }, + { + name: "policy", + mutate: func(app *AuthenticatedApp) { delete(app.Permissions, "contents") }, + wantCode: AppRegistrationImportPolicyMismatch, wantProblem: "permission:contents", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := newTestStore(t) + repository := NewAppRegistrationRepository(store, newFakeConnectionSecrets()) + request := validAppRegistrationImportRequest(t) + importer := NewAppRegistrationImporter(repository, importPublicResolver(), + func(int64, []byte) (AppRegistrationVerifier, error) { + return appRegistrationVerifierFunc(func(context.Context) (AuthenticatedApp, error) { + app := authenticatedAppForImport(t, request) + tt.mutate(&app) + return app, nil + }), nil + }) + _, err := importer.Import(context.Background(), request) + var importErr *AppRegistrationImportError + if !errors.As(err, &importErr) || importErr.Code != tt.wantCode || + !containsString(importErr.Problems, tt.wantProblem) { + t.Fatalf("import mismatch error = %#v", err) + } + for _, secret := range []string{request.ClientSecret, request.PrivateKey, request.WebhookSecret} { + if strings.Contains(err.Error(), secret) { + t.Fatalf("import error leaked credential material") + } + } + stored, getErr := store.GetAppRegistration(context.Background(), request.RegistrationID) + if getErr != nil || stored != nil { + t.Fatalf("mismatched registration persisted = %+v, err %v", stored, getErr) + } + }) + } +} + +func TestAppRegistrationImportBoundsSecretInputsBeforeVerification(t *testing.T) { + request := validAppRegistrationImportRequest(t) + request.PrivateKey = strings.Repeat("k", maxAppRegistrationPrivateKey+1) + importer := NewAppRegistrationImporter( + NewAppRegistrationRepository(newTestStore(t), newFakeConnectionSecrets()), + importPublicResolver(), + func(int64, []byte) (AppRegistrationVerifier, error) { + t.Fatal("oversized import reached verifier") + return nil, nil + }, + ) + _, err := importer.Import(context.Background(), request) + if !IsAppRegistrationImportError(err, AppRegistrationImportInvalidRequest) { + t.Fatalf("oversized import error = %#v", err) + } +} + +func TestAppRegistrationImportSanitizesVerificationFailure(t *testing.T) { + request := validAppRegistrationImportRequest(t) + importer := NewAppRegistrationImporter( + NewAppRegistrationRepository(newTestStore(t), newFakeConnectionSecrets()), + importPublicResolver(), + func(int64, []byte) (AppRegistrationVerifier, error) { + return nil, errors.New(request.ClientSecret + request.WebhookSecret + request.PrivateKey) + }, + ) + _, err := importer.Import(context.Background(), request) + if !IsAppRegistrationImportError(err, AppRegistrationImportVerificationFailed) { + t.Fatalf("verification failure = %#v", err) + } + for _, secret := range []string{request.ClientSecret, request.WebhookSecret, request.PrivateKey} { + if strings.Contains(err.Error(), secret) { + t.Fatal("verification error leaked imported credentials") + } + } +} + +func TestAppRegistrationImportRejectsWrongExposedWebhookConfig(t *testing.T) { + store := newTestStore(t) + repository := NewAppRegistrationRepository(store, newFakeConnectionSecrets()) + request := validAppRegistrationImportRequest(t) + importer := NewAppRegistrationImporter(repository, importPublicResolver(), + func(int64, []byte) (AppRegistrationVerifier, error) { + return appRegistrationVerifierWithWebhook{ + app: authenticatedAppForImport(t, request), + config: AppWebhookConfig{ + URL: "https://wrong.example/webhook", ContentType: "json", InsecureSSL: "0", + }, + }, nil + }) + _, err := importer.Import(context.Background(), request) + var importErr *AppRegistrationImportError + if !errors.As(err, &importErr) || importErr.Code != AppRegistrationImportPolicyMismatch || + !containsString(importErr.Problems, "webhook") { + t.Fatalf("webhook policy error = %#v", err) + } +} + +func TestAppRegistrationImportAcceptsDocumentedAuthenticatedAppShapeAndPersists(t *testing.T) { + store := newTestStore(t) + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + request := validAppRegistrationImportRequest(t) + verified := false + importer := NewAppRegistrationImporter(repository, importPublicResolver(), + func(appID int64, privateKey []byte) (AppRegistrationVerifier, error) { + if appID != request.AppID || string(privateKey) != request.PrivateKey { + t.Fatalf("verifier credentials = app %d key %q", appID, privateKey) + } + return appRegistrationVerifierFunc(func(context.Context) (AuthenticatedApp, error) { + verified = true + return authenticatedAppForImport(t, request), nil + }), nil + }) + + registration, err := importer.Import(context.Background(), request) + if err != nil { + t.Fatalf("Import() error = %v", err) + } + if !verified { + t.Fatal("registration persisted without verifying GitHub App identity") + } + if registration == nil || registration.ID != request.RegistrationID || + registration.Source != AppRegistrationSourceImported || registration.AppID != request.AppID { + t.Fatalf("imported registration = %+v", registration) + } + stored, credentials, err := repository.LoadRegistration(context.Background(), request.RegistrationID) + if err != nil || stored == nil || stored.ID != request.RegistrationID { + t.Fatalf("stored registration = %+v, err %v", stored, err) + } + if credentials.PrivateKey != request.PrivateKey || credentials.ClientSecret != request.ClientSecret || + credentials.WebhookSecret != request.WebhookSecret { + t.Fatalf("stored credentials do not match import request") + } +} + +func validAppRegistrationImportRequest(t *testing.T) AppRegistrationImportRequest { + t.Helper() + _, privateKey := testAppPrivateKey(t) + return AppRegistrationImportRequest{ + RegistrationID: "22222222-2222-4222-8222-222222222222", + WorkspaceID: "workspace-1", DisplayName: "Acme automation", GitHubHost: "github.com", + AppID: 123, ClientID: "Iv1.client", ClientSecret: "client-secret", + PrivateKey: string(privateKey), WebhookSecret: "webhook-secret", Slug: "kandev-acme", + OwnerLogin: "acme", OwnerType: AppRegistrationOwnerOrganization, + Visibility: AppRegistrationVisibilityPrivate, PublicBaseURL: "https://kandev.example", + } +} + +func authenticatedAppForImport( + t *testing.T, + request AppRegistrationImportRequest, +) AuthenticatedApp { + t.Helper() + submission, err := BuildAppRegistrationManifest(AppRegistrationManifestRequest{ + RegistrationID: request.RegistrationID, OwnerType: ManifestOwnerOrganization, + OwnerLogin: request.OwnerLogin, PublicBaseURL: request.PublicBaseURL, + Visibility: request.Visibility, + }) + if err != nil { + t.Fatal(err) + } + manifest := submission.Manifest + return AuthenticatedApp{ + ID: request.AppID, ClientID: request.ClientID, Slug: request.Slug, + Name: request.DisplayName, OwnerLogin: request.OwnerLogin, OwnerType: string(request.OwnerType), + ExternalURL: manifest.URL, + Permissions: manifest.DefaultPermissions, Events: manifest.DefaultEvents, + } +} + +func importPublicResolver() PublicGitHubBaseURLResolver { + return manifestResolverFunc(func(context.Context, string, string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("203.0.114.10")}, nil + }) +} diff --git a/apps/backend/internal/github/app_registration_lifecycle_service.go b/apps/backend/internal/github/app_registration_lifecycle_service.go new file mode 100644 index 0000000000..ae7b3ae3a7 --- /dev/null +++ b/apps/backend/internal/github/app_registration_lifecycle_service.go @@ -0,0 +1,566 @@ +package github + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "io" + "net/url" + "strings" + "time" + + "github.com/google/uuid" +) + +var ErrAppRegistrationNotFound = errors.New("GitHub App registration was not found") + +const appRegistrationImportPreparationTTL = 10 * time.Minute + +type AppRegistrationInUseError struct { + BindingCount int `json:"binding_count"` +} + +func (e *AppRegistrationInUseError) Error() string { + return "GitHub App registration is in use" +} + +func (e *AppRegistrationInUseError) Unwrap() error { return ErrDeploymentAppInUse } + +type AppRegistrationLifecycleConfig struct { + Repository *AppRegistrationRepository + Store *Store + Runtime appRegistrationRuntimeManager + Converter DeploymentAppManifestConverter + Resolver PublicGitHubBaseURLResolver + Importer *AppRegistrationImporter + Now func() time.Time + Random io.Reader +} + +type AppRegistrationLifecycleService struct { + repository *AppRegistrationRepository + store *Store + runtime appRegistrationRuntimeManager + converter DeploymentAppManifestConverter + resolver PublicGitHubBaseURLResolver + importer *AppRegistrationImporter + now func() time.Time + random io.Reader +} + +type appRegistrationRuntimeManager interface { + ValidateAppRegistrationRuntime(ResolvedDeploymentAppConfig) error + ApplyAppRegistrationRuntime(ResolvedDeploymentAppConfig) error + InvalidateAppRegistrationRuntime(string, int64) +} + +type AppRegistrationCatalogItem struct { + *AppRegistration + Selected bool `json:"selected"` + BindingCount int `json:"binding_count"` + WorkspaceBindingCount int `json:"workspace_binding_count"` + Shared bool `json:"shared"` + ManifestCallbackURL string `json:"manifest_callback_url"` + InstallCallbackURL string `json:"install_callback_url"` + PersonalCallbackURL string `json:"personal_callback_url"` + WebhookURL string `json:"webhook_url"` +} + +type AppRegistrationCatalog struct { + WorkspaceID string `json:"workspace_id"` + Registrations []AppRegistrationCatalogItem `json:"registrations"` +} + +type AppRegistrationManifestStartRequest struct { + WorkspaceID string `json:"workspace_id"` + DisplayName string `json:"display_name"` + OwnerType ManifestOwnerType `json:"owner_type"` + OwnerLogin string `json:"owner_login"` + Visibility AppRegistrationVisibility `json:"visibility"` + PublicBaseURL string `json:"public_base_url"` +} + +type AppRegistrationManifestStart struct { + RegistrationID string `json:"registration_id"` + WorkspaceID string `json:"workspace_id"` + State string `json:"state"` + ExpiresAt time.Time `json:"expires_at"` + Revision int `json:"revision"` + RegistrationURL string `json:"registration_url"` + Manifest DeploymentAppManifest `json:"manifest"` +} + +type AppRegistrationManifestCallback struct { + State string + Code string + Error string +} + +type AppRegistrationManifestResult struct { + WorkspaceID string `json:"workspace_id"` + RegistrationID string `json:"registration_id"` + Registration *AppRegistration `json:"registration"` +} + +type AppRegistrationImportPrepareRequest struct { + WorkspaceID string `json:"workspace_id"` + PublicBaseURL string `json:"public_base_url"` +} + +type AppRegistrationImportPreparationResult struct { + RegistrationID string `json:"registration_id"` + PublicBaseURL string `json:"public_base_url"` + ManifestCallbackURL string `json:"manifest_callback_url"` + InstallCallbackURL string `json:"install_callback_url"` + PersonalCallbackURL string `json:"personal_callback_url"` + WebhookURL string `json:"webhook_url"` + SetupURL string `json:"setup_url"` + Permissions map[string]string `json:"permissions"` + Events []string `json:"events"` + ExpiresAt time.Time `json:"expires_at"` +} + +func NewAppRegistrationLifecycleService( + cfg AppRegistrationLifecycleConfig, +) *AppRegistrationLifecycleService { + now := cfg.Now + if now == nil { + now = time.Now + } + random := cfg.Random + if random == nil { + random = rand.Reader + } + importer := cfg.Importer + if importer == nil && cfg.Repository != nil { + importer = NewAppRegistrationImporter(cfg.Repository, cfg.Resolver, nil) + } + return &AppRegistrationLifecycleService{ + repository: cfg.Repository, store: cfg.Store, runtime: cfg.Runtime, + converter: cfg.Converter, resolver: cfg.Resolver, importer: importer, + now: now, random: random, + } +} + +func (s *AppRegistrationLifecycleService) ready() error { + if s == nil || s.repository == nil || s.store == nil || s.runtime == nil { + return errors.New("GitHub App registration lifecycle is not configured") + } + return nil +} + +func (s *AppRegistrationLifecycleService) List( + ctx context.Context, + userID, workspaceID string, +) (AppRegistrationCatalog, error) { + if err := requireDeploymentAppOperator(ctx, userID); err != nil { + return AppRegistrationCatalog{}, err + } + if err := s.ready(); err != nil { + return AppRegistrationCatalog{}, err + } + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return AppRegistrationCatalog{}, ErrGitHubWorkspaceRequired + } + selected, err := s.store.GetWorkspaceConnection(ctx, workspaceID) + if err != nil { + return AppRegistrationCatalog{}, err + } + registrations, err := s.store.ListAppRegistrations(ctx) + if err != nil { + return AppRegistrationCatalog{}, err + } + result := AppRegistrationCatalog{WorkspaceID: workspaceID, Registrations: make([]AppRegistrationCatalogItem, 0, len(registrations))} + for _, registration := range registrations { + bindings, countErr := s.store.CountAppRegistrationBindings(ctx, registration.ID) + if countErr != nil { + return AppRegistrationCatalog{}, countErr + } + workspaceBindings, workspaceCountErr := s.store.CountAppRegistrationWorkspaceBindings( + ctx, registration.ID, + ) + if workspaceCountErr != nil { + return AppRegistrationCatalog{}, workspaceCountErr + } + item := appRegistrationCatalogItem(registration, bindings, workspaceBindings) + item.Selected = selected != nil && selected.AppRegistrationID == registration.ID + result.Registrations = append(result.Registrations, item) + } + return result, nil +} + +func appRegistrationCatalogItem( + registration *AppRegistration, + bindingCount, workspaceBindingCount int, +) AppRegistrationCatalogItem { + base := strings.TrimRight(registration.PublicBaseURL, "/") + + "/api/v1/github/app/registrations/" + registration.ID + return AppRegistrationCatalogItem{ + AppRegistration: cloneDeploymentAppRegistration(registration), BindingCount: bindingCount, + WorkspaceBindingCount: workspaceBindingCount, Shared: workspaceBindingCount > 1, + ManifestCallbackURL: base + "/manifest/callback", + InstallCallbackURL: base + "/install/callback", PersonalCallbackURL: base + "/personal/callback", + WebhookURL: base + "/webhook", + } +} + +func (s *AppRegistrationLifecycleService) StartManifest( + ctx context.Context, + userID string, + request AppRegistrationManifestStartRequest, +) (AppRegistrationManifestStart, error) { + if err := requireDeploymentAppOperator(ctx, userID); err != nil { + return AppRegistrationManifestStart{}, err + } + if err := s.ready(); err != nil { + return AppRegistrationManifestStart{}, err + } + request.WorkspaceID = strings.TrimSpace(request.WorkspaceID) + request.DisplayName = strings.TrimSpace(request.DisplayName) + if request.WorkspaceID == "" || request.DisplayName == "" || + len(request.DisplayName) > maxAppRegistrationDisplayName { + return AppRegistrationManifestStart{}, errors.New("workspace and display name are required") + } + baseURL, err := ValidatePublicGitHubBaseURL(ctx, request.PublicBaseURL, s.resolver) + if err != nil { + return AppRegistrationManifestStart{}, err + } + registrationID := uuid.NewString() + submission, err := BuildAppRegistrationManifest(AppRegistrationManifestRequest{ + RegistrationID: registrationID, OwnerType: request.OwnerType, OwnerLogin: request.OwnerLogin, + PublicBaseURL: baseURL, Visibility: request.Visibility, + }) + if err != nil { + return AppRegistrationManifestStart{}, err + } + state, err := randomBase64URL(s.random) + if err != nil { + return AppRegistrationManifestStart{}, errors.New("generate GitHub App manifest state") + } + now := s.now().UTC() + digest := sha256.Sum256([]byte(state)) + flow := &DeploymentAppRegistrationFlow{ + StateHash: stateDigestString(digest), RegistrationID: registrationID, + WorkspaceID: request.WorkspaceID, UserID: userID, OperatorUserID: userID, + OwnerType: deploymentOwnerType(request.OwnerType), OwnerLogin: request.OwnerLogin, + DisplayName: request.DisplayName, Visibility: normalizedAppVisibility(request.Visibility), + PublicBaseURL: baseURL, ManifestRevision: submission.Revision, + ExpiresAt: DeploymentAppManifestFlowExpiresAt(now), CreatedAt: now, + } + if err := s.store.CreateDeploymentAppRegistrationFlow(ctx, flow); err != nil { + return AppRegistrationManifestStart{}, fmt.Errorf("persist GitHub App manifest state: %w", err) + } + callback, err := url.Parse(submission.Manifest.RedirectURL) + if err != nil { + return AppRegistrationManifestStart{}, errors.New("build GitHub App manifest callback") + } + query := callback.Query() + query.Set("state", state) + callback.RawQuery = query.Encode() + submission.Manifest.RedirectURL = callback.String() + return AppRegistrationManifestStart{ + RegistrationID: registrationID, WorkspaceID: request.WorkspaceID, + State: state, ExpiresAt: flow.ExpiresAt, Revision: submission.Revision, + RegistrationURL: submission.RegistrationURL, Manifest: submission.Manifest, + }, nil +} + +func normalizedAppVisibility(value AppRegistrationVisibility) AppRegistrationVisibility { + if value == "" { + return AppRegistrationVisibilityPrivate + } + return value +} + +func (s *AppRegistrationLifecycleService) CompleteManifest( + ctx context.Context, + routeRegistrationID string, + callback AppRegistrationManifestCallback, +) (AppRegistrationManifestResult, error) { + if err := s.ready(); err != nil { + return AppRegistrationManifestResult{}, err + } + flow, err := s.consumeManifestFlow(ctx, callback.State, routeRegistrationID) + if err != nil { + return AppRegistrationManifestResult{}, err + } + if strings.TrimSpace(callback.Error) != "" || strings.TrimSpace(callback.Code) == "" { + return AppRegistrationManifestResult{}, ErrDeploymentAppRegistrationCancelled + } + if s.converter == nil { + return AppRegistrationManifestResult{}, errors.New("GitHub App manifest conversion is unavailable") + } + converted, err := s.converter.Convert(ctx, strings.TrimSpace(callback.Code)) + if err != nil { + return AppRegistrationManifestResult{}, err + } + if !matchesDeploymentAppOwner(flow, converted) { + return AppRegistrationManifestResult{}, ErrDeploymentAppIdentityMismatch + } + if !matchesDeploymentAppPolicy(converted) { + return AppRegistrationManifestResult{}, ErrDeploymentAppPolicyMismatch + } + registration := registrationFromManifestFlow(flow, converted, s.now().UTC()) + credentials := DeploymentAppCredentials{ + PrivateKey: converted.PrivateKeyPEM, ClientSecret: converted.ClientSecret, + WebhookSecret: converted.WebhookSecret, + } + resolved := resolvedDeploymentAppRegistration(registration, credentials) + if err := s.runtime.ValidateAppRegistrationRuntime(resolved); err != nil { + return AppRegistrationManifestResult{}, sanitizeDeploymentAppActivationError(err) + } + unlock := s.store.lockAppRegistrationLifecycle(registration.ID) + defer unlock() + if err := s.repository.SaveRegistration(ctx, registration, credentials); err != nil { + return AppRegistrationManifestResult{}, sanitizeDeploymentAppPersistenceError(err) + } + if err := s.runtime.ApplyAppRegistrationRuntime(resolved); err != nil { + cleanupErr := s.repository.DeleteRegistration(context.WithoutCancel(ctx), registration.ID) + return AppRegistrationManifestResult{}, sanitizeDeploymentAppActivationAndCleanupError(err, cleanupErr) + } + return AppRegistrationManifestResult{ + WorkspaceID: flow.WorkspaceID, RegistrationID: registration.ID, + Registration: cloneDeploymentAppRegistration(registration), + }, nil +} + +func (s *AppRegistrationLifecycleService) consumeManifestFlow( + ctx context.Context, + state, registrationID string, +) (*DeploymentAppRegistrationFlow, error) { + digest := sha256.Sum256([]byte(strings.TrimSpace(state))) + flow, err := s.store.ConsumeDeploymentAppRegistrationFlow( + ctx, stateDigestString(digest), registrationID, s.now().UTC(), + ) + if err != nil { + if errors.Is(err, ErrDeploymentAppFlowUnavailable) { + return nil, ErrDeploymentAppManifestStateUnavailable + } + return nil, fmt.Errorf("consume GitHub App manifest state: %w", err) + } + if flow == nil || flow.RegistrationID != registrationID || flow.UserID != DefaultUserID || + flow.ManifestRevision != DeploymentAppManifestRevision { + return nil, ErrDeploymentAppManifestStateUnavailable + } + return flow, nil +} + +func registrationFromManifestFlow( + flow *DeploymentAppRegistrationFlow, + converted ManifestConversionResult, + now time.Time, +) *AppRegistration { + return &AppRegistration{ + ID: flow.RegistrationID, Source: AppRegistrationSourceManaged, + DisplayName: flow.DisplayName, GitHubHost: defaultGitHubAppHost, + AppID: converted.AppID, ClientID: converted.ClientID, Slug: converted.Slug, + OwnerLogin: converted.Owner.Login, OwnerType: deploymentOwnerTypeFromGitHub(converted.Owner.Type), + Visibility: flow.Visibility, PublicBaseURL: flow.PublicBaseURL, + CreatedForWorkspaceID: flow.WorkspaceID, CredentialGeneration: 1, + Status: AppRegistrationStatusActive, WebhookStatus: DeploymentAppWebhookUnverified, + CreatedAt: now, UpdatedAt: now, + } +} + +func (s *AppRegistrationLifecycleService) Import( + ctx context.Context, + userID string, + request AppRegistrationImportRequest, +) (*AppRegistration, error) { + if err := requireDeploymentAppOperator(ctx, userID); err != nil { + return nil, err + } + if err := s.ready(); err != nil { + return nil, err + } + if s.importer == nil { + return nil, errors.New("GitHub App importer is not configured") + } + request.RegistrationID = strings.TrimSpace(request.RegistrationID) + request.WorkspaceID = strings.TrimSpace(request.WorkspaceID) + parsedID, parseErr := uuid.Parse(request.RegistrationID) + if parseErr != nil || parsedID.String() != request.RegistrationID || request.WorkspaceID == "" { + return nil, ErrAppRegistrationImportPreparationUnavailable + } + baseURL, err := ValidatePublicGitHubBaseURL(ctx, request.PublicBaseURL, s.resolver) + if err != nil { + return nil, err + } + request.PublicBaseURL = baseURL + if _, err := s.store.ConsumeAppRegistrationImportPreparation( + ctx, request.RegistrationID, request.WorkspaceID, userID, baseURL, s.now().UTC(), + ); err != nil { + return nil, err + } + unlock := s.store.lockAppRegistrationLifecycle(request.RegistrationID) + defer unlock() + registration, err := s.importer.Import(ctx, request) + if err != nil { + return nil, err + } + resolved, err := ResolveAppRegistrationConfig(ctx, registration.ID, s.repository) + if err == nil { + err = s.runtime.ApplyAppRegistrationRuntime(resolved) + } + if err != nil { + cleanupErr := s.repository.DeleteRegistration(context.WithoutCancel(ctx), registration.ID) + return nil, sanitizeDeploymentAppActivationAndCleanupError(err, cleanupErr) + } + return cloneDeploymentAppRegistration(registration), nil +} + +func (s *AppRegistrationLifecycleService) PrepareImport( + ctx context.Context, + userID string, + request AppRegistrationImportPrepareRequest, +) (AppRegistrationImportPreparationResult, error) { + if err := requireDeploymentAppOperator(ctx, userID); err != nil { + return AppRegistrationImportPreparationResult{}, err + } + if err := s.ready(); err != nil { + return AppRegistrationImportPreparationResult{}, err + } + request.WorkspaceID = strings.TrimSpace(request.WorkspaceID) + if request.WorkspaceID == "" || len(request.WorkspaceID) > maxAppRegistrationWorkspaceID { + return AppRegistrationImportPreparationResult{}, ErrGitHubWorkspaceRequired + } + baseURL, err := ValidatePublicGitHubBaseURL(ctx, request.PublicBaseURL, s.resolver) + if err != nil { + return AppRegistrationImportPreparationResult{}, err + } + registrationID := uuid.NewString() + now := s.now().UTC() + preparation := &AppRegistrationImportPreparation{ + RegistrationID: registrationID, WorkspaceID: request.WorkspaceID, UserID: userID, + PublicBaseURL: baseURL, ExpiresAt: now.Add(appRegistrationImportPreparationTTL), CreatedAt: now, + } + if err := s.store.CreateAppRegistrationImportPreparation(ctx, preparation); err != nil { + return AppRegistrationImportPreparationResult{}, fmt.Errorf("persist GitHub App import preparation: %w", err) + } + policy, err := BuildDeploymentAppManifest(ManifestOwnerUser, "policy", baseURL) + if err != nil { + return AppRegistrationImportPreparationResult{}, err + } + registrationBaseURL := strings.TrimRight(baseURL, "/") + + "/api/v1/github/app/registrations/" + registrationID + installCallbackURL := registrationBaseURL + "/install/callback" + return AppRegistrationImportPreparationResult{ + RegistrationID: registrationID, PublicBaseURL: baseURL, + ManifestCallbackURL: registrationBaseURL + "/manifest/callback", + InstallCallbackURL: installCallbackURL, + PersonalCallbackURL: registrationBaseURL + "/personal/callback", + WebhookURL: registrationBaseURL + "/webhook", SetupURL: installCallbackURL, + Permissions: policy.Manifest.DefaultPermissions, Events: policy.Manifest.DefaultEvents, + ExpiresAt: preparation.ExpiresAt, + }, nil +} + +func (s *AppRegistrationLifecycleService) Rename( + ctx context.Context, + userID, registrationID, displayName string, +) (*AppRegistration, error) { + if err := requireDeploymentAppOperator(ctx, userID); err != nil { + return nil, err + } + displayName = strings.TrimSpace(displayName) + if displayName == "" || len(displayName) > maxAppRegistrationDisplayName { + return nil, errors.New("GitHub App display name is invalid") + } + unlock := s.store.lockAppRegistrationLifecycle(registrationID) + defer unlock() + registration, err := s.store.RenameAppRegistration(ctx, registrationID, displayName) + if err != nil { + return nil, err + } + if registration == nil { + return nil, ErrAppRegistrationNotFound + } + return cloneDeploymentAppRegistration(registration), nil +} + +func (s *AppRegistrationLifecycleService) Delete( + ctx context.Context, + userID, registrationID string, +) error { + if err := requireDeploymentAppOperator(ctx, userID); err != nil { + return err + } + unlock := s.store.lockAppRegistrationLifecycle(registrationID) + defer unlock() + registration, err := s.store.GetAppRegistration(ctx, registrationID) + if err != nil { + return err + } + if registration == nil { + return ErrAppRegistrationNotFound + } + bindings, err := s.store.CountAppRegistrationBindings(ctx, registrationID) + if err != nil { + return err + } + if bindings > 0 { + return &AppRegistrationInUseError{BindingCount: bindings} + } + if err := s.repository.DeleteRegistration(ctx, registrationID); err != nil { + s.invalidateRuntimeForUnusableRegistration(ctx, registration) + return err + } + s.runtime.InvalidateAppRegistrationRuntime(registrationID, registration.CredentialGeneration) + return nil +} + +func (s *AppRegistrationLifecycleService) ResetForE2E( + ctx context.Context, + workspaceID string, +) error { + if err := s.ready(); err != nil { + return err + } + if workspaceID != "" { + if err := s.store.DeleteAppRegistrationFlowsByWorkspace(ctx, workspaceID); err != nil { + return err + } + } + if err := s.store.DeleteAppRegistrationImportPreparationsByWorkspace(ctx, workspaceID); err != nil { + return err + } + registrations, err := s.store.ListAppRegistrations(ctx) + if err != nil { + return err + } + for _, registration := range registrations { + if workspaceID != "" && registration.CreatedForWorkspaceID != workspaceID { + continue + } + bindings, countErr := s.store.CountAppRegistrationBindings(ctx, registration.ID) + if countErr != nil { + return countErr + } + if bindings != 0 { + continue + } + unlock := s.store.lockAppRegistrationLifecycle(registration.ID) + if err := s.repository.DeleteRegistration(ctx, registration.ID); err != nil { + unlock() + s.invalidateRuntimeForUnusableRegistration(ctx, registration) + return err + } + s.runtime.InvalidateAppRegistrationRuntime(registration.ID, registration.CredentialGeneration) + unlock() + } + return s.repository.CleanupOrphanedCredentialBundles(ctx) +} + +func (s *AppRegistrationLifecycleService) invalidateRuntimeForUnusableRegistration( + ctx context.Context, + previous *AppRegistration, +) { + current, _, err := s.repository.LoadRegistration(context.WithoutCancel(ctx), previous.ID) + if err != nil || current == nil || current.Status != AppRegistrationStatusActive || + current.CredentialGeneration != previous.CredentialGeneration { + s.runtime.InvalidateAppRegistrationRuntime(previous.ID, previous.CredentialGeneration) + } +} diff --git a/apps/backend/internal/github/app_registration_store_test.go b/apps/backend/internal/github/app_registration_store_test.go new file mode 100644 index 0000000000..6a287dbaac --- /dev/null +++ b/apps/backend/internal/github/app_registration_store_test.go @@ -0,0 +1,634 @@ +package github + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func TestAppRegistrationSchemaUsesCatalogInsteadOfSingleton(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + for _, table := range []string{ + "github_app_registrations", + "github_app_registration_flows", + } { + var count int + if err := store.ro.GetContext(ctx, &count, + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table); err != nil { + t.Fatalf("read table %s: %v", table, err) + } + if count != 1 { + t.Errorf("table %s count = %d, want 1", table, count) + } + } + + for _, table := range []string{ + "github_app_registration", + "github_app_registration_flow_head", + } { + var count int + if err := store.ro.GetContext(ctx, &count, + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table); err != nil { + t.Fatalf("read table %s: %v", table, err) + } + if count != 0 { + t.Errorf("unpublished singleton table %s still exists", table) + } + } +} + +func TestAppRegistrationStorePersistsMultipleRegistrations(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + registrations := []*AppRegistration{ + newAppRegistration("registration-work", 101, "Work", now), + newAppRegistration("registration-personal", 202, "Personal", now), + } + for _, registration := range registrations { + if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil { + t.Fatalf("save registration %s: %v", registration.ID, err) + } + } + + for _, want := range registrations { + var got AppRegistration + err := store.ro.GetContext(ctx, &got, ` + SELECT id, source, display_name, github_host, app_id, client_id, slug, owner_login, + owner_type, visibility, public_base_url, COALESCE(created_for_workspace_id, '') AS created_for_workspace_id, + credential_generation, credential_secret_id, status, webhook_status, last_webhook_at, + COALESCE(last_error, '') AS last_error, created_at, updated_at + FROM github_app_registrations WHERE id = ?`, want.ID) + if err != nil { + t.Fatalf("get registration %s: %v", want.ID, err) + } + if got.ID != want.ID || got.AppID != want.AppID || got.DisplayName != want.DisplayName { + t.Fatalf("registration %s = %+v, want %+v", want.ID, got, want) + } + } + + var got []*AppRegistration + if err := store.ro.SelectContext(ctx, &got, ` + SELECT id, source, display_name, github_host, app_id, client_id, slug, owner_login, + owner_type, visibility, public_base_url, COALESCE(created_for_workspace_id, '') AS created_for_workspace_id, + credential_generation, credential_secret_id, status, webhook_status, last_webhook_at, + COALESCE(last_error, '') AS last_error, created_at, updated_at + FROM github_app_registrations ORDER BY id`); err != nil { + t.Fatalf("list registrations: %v", err) + } + if len(got) != 2 || got[0].ID != "registration-personal" || got[1].ID != "registration-work" { + t.Fatalf("registrations = %+v", got) + } +} + +func newAppRegistration(id string, appID int64, displayName string, now time.Time) *AppRegistration { + return &AppRegistration{ + ID: id, Source: AppRegistrationSourceManaged, DisplayName: displayName, + GitHubHost: "github.com", AppID: appID, ClientID: "Iv1.client", Slug: "kandev-" + id, + OwnerLogin: "acme", OwnerType: AppRegistrationOwnerOrganization, + Visibility: AppRegistrationVisibilityPrivate, PublicBaseURL: "https://kandev.example.com", + CredentialGeneration: 1, CredentialSecretID: "github:app-registration:" + id + ":g1:test", + Status: AppRegistrationStatusActive, WebhookStatus: DeploymentAppWebhookUnverified, + CreatedAt: now, UpdatedAt: now, + } +} + +func TestAppRegistrationReferencesRoundTrip(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-1") + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + registration := newAppRegistration("registration-1", 101, "Work", now) + if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil { + t.Fatal(err) + } + installationID := int64(42) + if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ + WorkspaceID: "ws-1", Source: ConnectionSourceGitHubAppInstallation, + GitHubHost: "github.com", InstallationID: &installationID, + InstallationAccountLogin: "acme", InstallationAccountType: "Organization", + AppRegistrationID: registration.ID, Status: ConnectionStatusActive, + }); err != nil { + t.Fatalf("save workspace registration reference: %v", err) + } + workspace, err := store.GetWorkspaceConnection(ctx, "ws-1") + if err != nil || workspace == nil || workspace.AppRegistrationID != registration.ID { + t.Fatalf("workspace registration reference = %+v, err %v", workspace, err) + } + + if err := store.UpsertUserConnection(ctx, &UserConnection{ + WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: registration.ID, + GitHubUserID: 7, Login: "octocat", Status: ConnectionStatusActive, + AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + }); err != nil { + t.Fatalf("save personal registration reference: %v", err) + } + personal, err := store.GetUserConnection(ctx, "ws-1", "user-1") + if err != nil || personal == nil || personal.AppRegistrationID != registration.ID { + t.Fatalf("personal registration reference = %+v, err %v", personal, err) + } + + flow := &AuthFlow{ + StateHash: "state", WorkspaceID: "ws-1", UserID: "user-1", + AppRegistrationID: registration.ID, Kind: AuthFlowKindPersonal, + ExpiresAt: now.Add(time.Hour), + } + if err := store.CreateAuthFlow(ctx, flow); err != nil { + t.Fatalf("save auth flow registration reference: %v", err) + } + storedFlow, err := store.GetAuthFlow(ctx, flow.StateHash) + if err != nil || storedFlow == nil || storedFlow.AppRegistrationID != registration.ID { + t.Fatalf("auth flow registration reference = %+v, err %v", storedFlow, err) + } +} + +func TestPersonalConnectionRejectsDifferentWorkspaceRegistration(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-1") + ctx := context.Background() + now := time.Now().UTC() + work := newAppRegistration("registration-work", 101, "Work", now) + personal := newAppRegistration("registration-personal", 202, "Personal", now) + for _, registration := range []*AppRegistration{work, personal} { + if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil { + t.Fatal(err) + } + } + installationID := int64(42) + if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ + WorkspaceID: "ws-1", Source: ConnectionSourceGitHubAppInstallation, + GitHubHost: "github.com", InstallationID: &installationID, + InstallationAccountLogin: "acme", InstallationAccountType: "Organization", + AppRegistrationID: work.ID, Status: ConnectionStatusActive, + }); err != nil { + t.Fatal(err) + } + err := store.UpsertUserConnection(ctx, &UserConnection{ + WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: personal.ID, + GitHubUserID: 7, Login: "octocat", Status: ConnectionStatusActive, + AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + }) + if !errors.Is(err, ErrOAuthFlowStale) { + t.Fatalf("mismatched personal registration error = %v, want %v", err, ErrOAuthFlowStale) + } +} + +func TestAppRegistrationRepositoryKeepsCredentialBundlesIndependent(t *testing.T) { + store := newTestStore(t) + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + ctx := context.Background() + now := time.Now().UTC() + work := newAppRegistration("registration-work", 101, "Work", now) + personal := newAppRegistration("registration-personal", 202, "Personal", now) + workCredentials := DeploymentAppCredentials{ + PrivateKey: "work-key", ClientSecret: "work-client", WebhookSecret: "work-webhook", + } + personalCredentials := DeploymentAppCredentials{ + PrivateKey: "personal-key", ClientSecret: "personal-client", WebhookSecret: "personal-webhook", + } + if err := repository.SaveRegistration(ctx, work, workCredentials); err != nil { + t.Fatalf("save work registration: %v", err) + } + if err := repository.SaveRegistration(ctx, personal, personalCredentials); err != nil { + t.Fatalf("save personal registration: %v", err) + } + if work.CredentialSecretID == personal.CredentialSecretID || len(secrets.values) != 2 { + t.Fatalf("credential bundles are not independent: work=%q personal=%q values=%v", + work.CredentialSecretID, personal.CredentialSecretID, secrets.values) + } + gotWork, gotWorkCredentials, err := repository.LoadRegistration(ctx, work.ID) + if err != nil || gotWork == nil || gotWorkCredentials != workCredentials { + t.Fatalf("loaded work registration = %+v, credentials = %+v, err %v", + gotWork, gotWorkCredentials, err) + } + gotPersonal, gotPersonalCredentials, err := repository.LoadRegistration(ctx, personal.ID) + if err != nil || gotPersonal == nil || gotPersonalCredentials != personalCredentials { + t.Fatalf("loaded personal registration = %+v, credentials = %+v, err %v", + gotPersonal, gotPersonalCredentials, err) + } +} + +func TestAppRegistrationDeleteIsRestrictedByWorkspaceAndPersonalReferences(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-1") + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + ctx := context.Background() + now := time.Now().UTC() + registration := newAppRegistration("registration-1", 101, "Work", now) + credentials := DeploymentAppCredentials{ + PrivateKey: "key", ClientSecret: "client", WebhookSecret: "webhook", + } + if err := repository.SaveRegistration(ctx, registration, credentials); err != nil { + t.Fatal(err) + } + installationID := int64(42) + if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ + WorkspaceID: "ws-1", Source: ConnectionSourceGitHubAppInstallation, + GitHubHost: "github.com", InstallationID: &installationID, + InstallationAccountLogin: "acme", InstallationAccountType: "Organization", + AppRegistrationID: registration.ID, Status: ConnectionStatusActive, + }); err != nil { + t.Fatal(err) + } + if err := store.UpsertUserConnection(ctx, &UserConnection{ + WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: registration.ID, + GitHubUserID: 7, Login: "octocat", Status: ConnectionStatusActive, + AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + }); err != nil { + t.Fatal(err) + } + if err := repository.DeleteRegistration(ctx, registration.ID); !errors.Is(err, ErrDeploymentAppInUse) { + t.Fatalf("delete referenced registration error = %v, want %v", err, ErrDeploymentAppInUse) + } + if err := store.DeleteUserConnection(ctx, "ws-1", "user-1"); err != nil { + t.Fatal(err) + } + if err := store.DeleteWorkspaceConnection(ctx, "ws-1"); err != nil { + t.Fatal(err) + } + if err := repository.DeleteRegistration(ctx, registration.ID); err != nil { + t.Fatalf("delete unreferenced registration: %v", err) + } + if len(secrets.values) != 0 { + t.Fatalf("registration credentials remain after delete: %v", secrets.values) + } +} + +func TestAppRegistrationDeletePreservesRegistrationWhenSecretDeleteFails(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + ctx := context.Background() + registration := newAppRegistration("registration-delete-failure", 101, "Work", time.Now().UTC()) + credentials := DeploymentAppCredentials{ + PrivateKey: "private-key", ClientSecret: "client-secret", WebhookSecret: "webhook-secret", + } + if err := repository.SaveRegistration(ctx, registration, credentials); err != nil { + t.Fatal(err) + } + secrets.deleteErr = errors.New("backend detail containing private-key") + secrets.deleteErrKey = registration.CredentialSecretID + + err := repository.DeleteRegistration(ctx, registration.ID) + if !errors.Is(err, ErrAppRegistrationCredentialCleanup) { + t.Fatalf("DeleteRegistration() error = %v, want credential cleanup error", err) + } + if strings.Contains(err.Error(), "private-key") { + t.Fatalf("DeleteRegistration() leaked secret-store detail: %v", err) + } + got, gotCredentials, loadErr := repository.LoadRegistration(ctx, registration.ID) + if loadErr != nil || got == nil || got.Status != AppRegistrationStatusActive || gotCredentials != credentials { + t.Fatalf("registration after failed delete = %+v, credentials %+v, err %v", got, gotCredentials, loadErr) + } + + secrets.deleteErr = nil + if err := repository.DeleteRegistration(ctx, registration.ID); err != nil { + t.Fatalf("retry DeleteRegistration(): %v", err) + } +} + +func TestAppRegistrationDeleteRestoresAmbiguouslyDeletedSecret(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + ctx := context.Background() + registration := newAppRegistration("registration-ambiguous-delete", 101, "Work", time.Now().UTC()) + credentials := DeploymentAppCredentials{ + PrivateKey: "private-key", ClientSecret: "client-secret", WebhookSecret: "webhook-secret", + } + if err := repository.SaveRegistration(ctx, registration, credentials); err != nil { + t.Fatal(err) + } + secrets.deleteErr = errors.New("ambiguous delete") + secrets.deleteErrKey = registration.CredentialSecretID + secrets.deleteThenError = true + + err := repository.DeleteRegistration(ctx, registration.ID) + if !errors.Is(err, ErrAppRegistrationCredentialCleanup) { + t.Fatalf("DeleteRegistration() error = %v, want credential cleanup error", err) + } + got, gotCredentials, loadErr := repository.LoadRegistration(ctx, registration.ID) + if loadErr != nil || got == nil || got.Status != AppRegistrationStatusActive || gotCredentials != credentials { + t.Fatalf("registration after ambiguous delete = %+v, credentials %+v, err %v", got, gotCredentials, loadErr) + } +} + +func TestAppRegistrationDeleteRestoresSecretWhenMetadataDeleteFails(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + ctx := context.Background() + registration := newAppRegistration("registration-metadata-delete-failure", 101, "Work", time.Now().UTC()) + credentials := DeploymentAppCredentials{ + PrivateKey: "private-key", ClientSecret: "client-secret", WebhookSecret: "webhook-secret", + } + if err := repository.SaveRegistration(ctx, registration, credentials); err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec(` + CREATE TRIGGER fail_app_registration_delete + BEFORE DELETE ON github_app_registrations + BEGIN SELECT RAISE(FAIL, 'database detail'); END`); err != nil { + t.Fatal(err) + } + + err := repository.DeleteRegistration(ctx, registration.ID) + if !errors.Is(err, ErrAppRegistrationDeletionFailed) { + t.Fatalf("DeleteRegistration() error = %v, want registration deletion error", err) + } + if strings.Contains(err.Error(), "database detail") { + t.Fatalf("DeleteRegistration() leaked database detail: %v", err) + } + got, gotCredentials, loadErr := repository.LoadRegistration(ctx, registration.ID) + if loadErr != nil || got == nil || got.Status != AppRegistrationStatusActive || gotCredentials != credentials { + t.Fatalf("registration after metadata failure = %+v, credentials %+v, err %v", got, gotCredentials, loadErr) + } +} + +func TestAppRegistrationFlowsSupersedeOnlyWithinWorkspace(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-1", "ws-2") + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + newFlow := func(state, registrationID, workspaceID string, createdAt time.Time) *DeploymentAppRegistrationFlow { + return &DeploymentAppRegistrationFlow{ + StateHash: state, RegistrationID: registrationID, WorkspaceID: workspaceID, + UserID: "user-1", OwnerType: AppRegistrationOwnerOrganization, + OwnerLogin: "acme", DisplayName: "Kandev", Visibility: AppRegistrationVisibilityPrivate, + PublicBaseURL: "https://kandev.example.com", ManifestRevision: 1, + ExpiresAt: createdAt.Add(time.Hour), CreatedAt: createdAt, + } + } + first := newFlow("state-first", "registration-first", "ws-1", now) + other := newFlow("state-other", "registration-other", "ws-2", now.Add(time.Second)) + replacement := newFlow("state-replacement", "registration-replacement", "ws-1", now.Add(2*time.Second)) + for _, flow := range []*DeploymentAppRegistrationFlow{first, other, replacement} { + if err := store.CreateDeploymentAppRegistrationFlow(ctx, flow); err != nil { + t.Fatalf("create flow %s: %v", flow.StateHash, err) + } + } + storedFirst, err := store.GetDeploymentAppRegistrationFlow(ctx, first.StateHash) + if err != nil || storedFirst == nil || storedFirst.ConsumedAt == nil { + t.Fatalf("superseded first flow = %+v, err %v", storedFirst, err) + } + if _, err := store.ConsumeDeploymentAppRegistrationFlow( + ctx, other.StateHash, other.RegistrationID, now.Add(3*time.Second), + ); err != nil { + t.Fatalf("other workspace flow was superseded: %v", err) + } + consumed, err := store.ConsumeDeploymentAppRegistrationFlow( + ctx, replacement.StateHash, replacement.RegistrationID, now.Add(3*time.Second), + ) + if err != nil || consumed == nil || consumed.RegistrationID != replacement.RegistrationID { + t.Fatalf("replacement flow = %+v, err %v", consumed, err) + } +} + +func TestAppRegistrationWebhookDeliveryIdentityIsComposite(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + for _, registration := range []*AppRegistration{ + newAppRegistration("registration-1", 101, "Work", now), + newAppRegistration("registration-2", 202, "Personal", now), + } { + if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil { + t.Fatal(err) + } + } + for _, registrationID := range []string{"registration-1", "registration-2"} { + inserted, err := store.RecordWebhookDelivery(ctx, &WebhookDelivery{ + AppRegistrationID: registrationID, DeliveryID: "delivery-1", Event: "installation", + }) + if err != nil || !inserted { + t.Fatalf("record %s/delivery-1: inserted %v, err %v", registrationID, inserted, err) + } + } + inserted, err := store.RecordWebhookDelivery(ctx, &WebhookDelivery{ + AppRegistrationID: "registration-1", DeliveryID: "delivery-1", Event: "installation", + }) + if err != nil || inserted { + t.Fatalf("duplicate registration delivery: inserted %v, err %v", inserted, err) + } +} + +func TestAppRegistrationSchemaDropsUnpublishedSingletonState(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if _, err := store.db.Exec(` + CREATE TABLE github_app_registration ( + singleton_id INTEGER PRIMARY KEY, + github_host TEXT NOT NULL, + app_id BIGINT NOT NULL + ); + INSERT INTO github_app_registration (singleton_id, github_host, app_id) + VALUES (1, 'github.com', 999) + `); err != nil { + t.Fatal(err) + } + if _, err := NewStore(store.db, store.ro); err != nil { + t.Fatalf("replay store with unpublished singleton: %v", err) + } + var catalogCount int + if err := store.ro.GetContext(ctx, &catalogCount, `SELECT COUNT(*) FROM github_app_registrations`); err != nil { + t.Fatal(err) + } + if catalogCount != 0 { + t.Fatalf("unpublished singleton migrated into catalog: count = %d", catalogCount) + } + var singletonCount int + if err := store.ro.GetContext(ctx, &singletonCount, + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'github_app_registration'`); err != nil { + t.Fatal(err) + } + if singletonCount != 0 { + t.Fatalf("unpublished singleton still exists: count = %d", singletonCount) + } +} + +func TestPersonalConnectionDatabaseRejectsDifferentWorkspaceRegistration(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-1") + ctx := context.Background() + now := time.Now().UTC() + for _, registration := range []*AppRegistration{ + newAppRegistration("registration-work", 101, "Work", now), + newAppRegistration("registration-personal", 202, "Personal", now), + } { + if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil { + t.Fatal(err) + } + } + installationID := int64(42) + if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ + WorkspaceID: "ws-1", Source: ConnectionSourceGitHubAppInstallation, + GitHubHost: defaultGitHubHost, InstallationID: &installationID, + InstallationAccountLogin: "acme", InstallationAccountType: "Organization", + AppRegistrationID: "registration-work", Status: ConnectionStatusActive, + CredentialGeneration: 1, + }); err != nil { + t.Fatal(err) + } + _, err := store.db.ExecContext(ctx, ` + INSERT INTO github_user_connections ( + workspace_id, user_id, app_registration_id, github_user_id, login, status, + access_expires_at, credential_generation, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + "ws-1", "user-1", "registration-personal", 7, "octocat", "active", + now.Add(time.Hour), 1, now, now) + if err == nil || !strings.Contains(err.Error(), "must match workspace") { + t.Fatalf("mismatched direct insert error = %v", err) + } +} + +func TestWorkspaceConnectionDatabaseRejectsSwitchWithPersonalConnection(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-1") + ctx := context.Background() + now := time.Now().UTC() + for _, registration := range []*AppRegistration{ + newAppRegistration("registration-work", 101, "Work", now), + newAppRegistration("registration-personal", 202, "Personal", now), + } { + if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil { + t.Fatal(err) + } + } + installationID := int64(42) + if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ + WorkspaceID: "ws-1", Source: ConnectionSourceGitHubAppInstallation, + GitHubHost: defaultGitHubHost, InstallationID: &installationID, + InstallationAccountLogin: "acme", InstallationAccountType: "Organization", + AppRegistrationID: "registration-work", Status: ConnectionStatusActive, + CredentialGeneration: 1, + }); err != nil { + t.Fatal(err) + } + if err := store.UpsertUserConnection(ctx, &UserConnection{ + WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: "registration-work", + GitHubUserID: 7, Login: "octocat", Status: ConnectionStatusActive, + AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + }); err != nil { + t.Fatal(err) + } + + _, err := store.db.ExecContext(ctx, ` + UPDATE github_workspace_connections + SET app_registration_id = ? + WHERE workspace_id = ?`, "registration-personal", "ws-1") + if err == nil || !strings.Contains(err.Error(), "must match personal") { + t.Fatalf("direct workspace switch error = %v", err) + } + + _, err = store.db.ExecContext(ctx, + `DELETE FROM github_workspace_connections WHERE workspace_id = ?`, "ws-1") + if err == nil || !strings.Contains(err.Error(), "still has personal") { + t.Fatalf("direct workspace disconnect error = %v", err) + } +} + +func TestAppRegistrationImportPreparationIsExactAndSingleUse(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + preparation := &AppRegistrationImportPreparation{ + RegistrationID: "d8fcfe25-6dbc-45d2-8d11-8a0c1f022c85", + WorkspaceID: "workspace-1", UserID: DefaultUserID, + PublicBaseURL: "https://kandev.example", ExpiresAt: now.Add(time.Minute), CreatedAt: now, + } + if err := store.CreateAppRegistrationImportPreparation(ctx, preparation); err != nil { + t.Fatal(err) + } + for _, attempt := range []struct { + workspaceID, userID, publicBaseURL string + }{ + {"wrong-workspace", DefaultUserID, preparation.PublicBaseURL}, + {preparation.WorkspaceID, "wrong-user", preparation.PublicBaseURL}, + {preparation.WorkspaceID, DefaultUserID, "https://other.example"}, + } { + if _, err := store.ConsumeAppRegistrationImportPreparation( + ctx, preparation.RegistrationID, attempt.workspaceID, attempt.userID, attempt.publicBaseURL, now, + ); !errors.Is(err, ErrAppRegistrationImportPreparationUnavailable) { + t.Fatalf("wrong binding error = %v", err) + } + } + consumed, err := store.ConsumeAppRegistrationImportPreparation( + ctx, preparation.RegistrationID, preparation.WorkspaceID, preparation.UserID, + preparation.PublicBaseURL, now, + ) + if err != nil || consumed == nil || consumed.ConsumedAt == nil { + t.Fatalf("consume = %+v, err %v", consumed, err) + } + if _, err := store.ConsumeAppRegistrationImportPreparation( + ctx, preparation.RegistrationID, preparation.WorkspaceID, preparation.UserID, + preparation.PublicBaseURL, now, + ); !errors.Is(err, ErrAppRegistrationImportPreparationUnavailable) { + t.Fatalf("replay error = %v", err) + } +} + +func TestAppRegistrationImportPreparationCannotOverwriteReservedID(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + ctx := context.Background() + now := time.Now().UTC() + registrationID := "e72d588e-e0cb-44d4-ae7d-61a6171d4d1a" + preparation := &AppRegistrationImportPreparation{ + RegistrationID: registrationID, WorkspaceID: "workspace-1", UserID: DefaultUserID, + PublicBaseURL: "https://kandev.example", ExpiresAt: now.Add(time.Minute), CreatedAt: now, + } + if err := store.CreateAppRegistrationImportPreparation(ctx, preparation); err != nil { + t.Fatal(err) + } + existing := newAppRegistration(registrationID, 999, "Existing", now) + if err := store.UpsertDeploymentAppRegistration(ctx, existing); err != nil { + t.Fatal(err) + } + if _, err := store.ConsumeAppRegistrationImportPreparation( + ctx, registrationID, preparation.WorkspaceID, preparation.UserID, + preparation.PublicBaseURL, now, + ); !errors.Is(err, ErrAppRegistrationImportPreparationUnavailable) { + t.Fatalf("existing ID consume error = %v", err) + } + stored, err := store.GetAppRegistration(ctx, registrationID) + if err != nil || stored == nil || stored.AppID != 999 || stored.DisplayName != "Existing" { + t.Fatalf("existing registration changed = %+v, err %v", stored, err) + } +} + +func TestCreateAppRegistrationNeverOverwritesExistingID(t *testing.T) { + store := newTestStore(t) + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + ctx := context.Background() + now := time.Now().UTC() + existing := newAppRegistration("registration-existing", 101, "Existing", now) + existingCredentials := DeploymentAppCredentials{ + PrivateKey: "existing-key", ClientSecret: "existing-client", WebhookSecret: "existing-webhook", + } + if err := repository.SaveRegistration(ctx, existing, existingCredentials); err != nil { + t.Fatal(err) + } + candidate := newAppRegistration(existing.ID, 202, "Replacement", now) + err := repository.CreateRegistration(ctx, candidate, DeploymentAppCredentials{ + PrivateKey: "replacement-key", ClientSecret: "replacement-client", WebhookSecret: "replacement-webhook", + }) + if err == nil { + t.Fatal("create-only registration overwrote existing ID") + } + stored, credentials, err := repository.LoadRegistration(ctx, existing.ID) + if err != nil || stored == nil || stored.AppID != existing.AppID || stored.DisplayName != existing.DisplayName || + credentials != existingCredentials { + t.Fatalf("existing registration changed = %+v, credentials=%+v, err %v", stored, credentials, err) + } +} diff --git a/apps/backend/internal/github/app_registration_test_helpers_test.go b/apps/backend/internal/github/app_registration_test_helpers_test.go new file mode 100644 index 0000000000..7c1cd0a930 --- /dev/null +++ b/apps/backend/internal/github/app_registration_test_helpers_test.go @@ -0,0 +1,30 @@ +package github + +import ( + "context" + "testing" +) + +type manifestConverterFunc func(context.Context, string) (ManifestConversionResult, error) + +func (f manifestConverterFunc) Convert(ctx context.Context, code string) (ManifestConversionResult, error) { + return f(ctx, code) +} + +func testManifestConversion(t *testing.T, ownerLogin, ownerType string) ManifestConversionResult { + t.Helper() + _, privateKey := testAppPrivateKey(t) + return ManifestConversionResult{ + AppID: 123, Slug: "kandev-acme", Name: "Kandev Acme", + Owner: ManifestConversionOwner{ID: 42, Login: ownerLogin, Type: ownerType}, + ClientID: "Iv1.client", ClientSecret: "generated-client", + WebhookSecret: "generated-webhook", PrivateKeyPEM: string(privateKey), + Permissions: map[string]string{ + "actions": "read", "administration": "read", "checks": "read", + "contents": "write", "issues": "write", "members": "read", + "metadata": "read", "pull_requests": "write", "statuses": "read", + "workflows": "write", + }, + Events: []string{"installation", "installation_repositories", "github_app_authorization"}, + } +} diff --git a/apps/backend/internal/github/app_token_cache.go b/apps/backend/internal/github/app_token_cache.go new file mode 100644 index 0000000000..41dd62f873 --- /dev/null +++ b/apps/backend/internal/github/app_token_cache.go @@ -0,0 +1,255 @@ +package github + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +const ( + installationTokenRefreshMargin = 5 * time.Minute + installationTokenMintTimeout = 30 * time.Second +) + +var ( + ErrInstallationTokenExpired = errors.New("GitHub App installation token is expired") + ErrInstallationTokenInvalidated = errors.New("GitHub App installation token was invalidated") +) + +// InstallationTokenMinter mints one permission-scoped token. +type InstallationTokenMinter interface { + MintInstallationToken(context.Context, int64, InstallationPermissions, []string) (InstallationToken, error) +} + +type installationTokenCacheKey struct { + appRegistrationID string + appCredentialGeneration int64 + workspaceID string + installationID int64 + permissions string + repositories string +} + +type installationTokenCacheEntry struct { + token InstallationToken +} + +// InstallationTokenCache keeps GitHub's short-lived installation tokens in +// memory and coalesces concurrent refreshes for the same installation/scope. +type InstallationTokenCache struct { + registrationID string + credentialGeneration int64 + minter InstallationTokenMinter + + mu sync.RWMutex + entries map[installationTokenCacheKey]installationTokenCacheEntry + generations map[int64]uint64 + group singleflight.Group + now func() time.Time +} + +func NewInstallationTokenCache(minter InstallationTokenMinter) *InstallationTokenCache { + return NewAppInstallationTokenCache("", 0, minter) +} + +func NewAppInstallationTokenCache( + registrationID string, + credentialGeneration int64, + minter InstallationTokenMinter, +) *InstallationTokenCache { + return &InstallationTokenCache{ + registrationID: registrationID, credentialGeneration: credentialGeneration, minter: minter, + entries: make(map[installationTokenCacheKey]installationTokenCacheEntry), + generations: make(map[int64]uint64), + now: time.Now, + } +} + +// Get returns a valid token and begins refreshing before expiry. Concurrent +// callers for the same installation and permission scope share one mint. +func (c *InstallationTokenCache) Get( + ctx context.Context, + installationID int64, + permissions InstallationPermissions, + repositories []string, +) (InstallationToken, error) { + return c.GetForWorkspace(ctx, "", installationID, permissions, repositories) +} + +func (c *InstallationTokenCache) GetForWorkspace( + ctx context.Context, + workspaceID string, + installationID int64, + permissions InstallationPermissions, + repositories []string, +) (InstallationToken, error) { + if c == nil || c.minter == nil { + return InstallationToken{}, errors.New("GitHub App installation token minter is not configured") + } + permissions = clonePermissions(permissions) + repositories = canonicalRepositories(repositories) + key := installationTokenCacheKey{ + appRegistrationID: c.registrationID, appCredentialGeneration: c.credentialGeneration, + workspaceID: workspaceID, installationID: installationID, + permissions: canonicalPermissions(permissions), repositories: strings.Join(repositories, ","), + } + if token, ok := c.cached(key, installationTokenRefreshMargin); ok { + return token, nil + } + + generation := c.generation(installationID) + flightKey := fmt.Sprintf( + "%s:%d:%s:%d:%d:%s:%s", + c.registrationID, c.credentialGeneration, workspaceID, installationID, generation, + key.permissions, key.repositories, + ) + result := c.refresh(flightKey, ctx, key, installationID, generation, permissions, repositories) + select { + case <-ctx.Done(): + return InstallationToken{}, ctx.Err() + case resolved := <-result: + return c.resolveRefreshResult(key, resolved) + } +} + +func (c *InstallationTokenCache) refresh( + flightKey string, + ctx context.Context, + key installationTokenCacheKey, + installationID int64, + generation uint64, + permissions InstallationPermissions, + repositories []string, +) <-chan singleflight.Result { + return c.group.DoChan(flightKey, func() (any, error) { + if token, ok := c.cached(key, installationTokenRefreshMargin); ok { + return token, nil + } + mintCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), installationTokenMintTimeout) + defer cancel() + token, err := c.minter.MintInstallationToken( + mintCtx, installationID, clonePermissions(permissions), append([]string(nil), repositories...), + ) + if err != nil { + return InstallationToken{}, err + } + if token.Token == "" || !token.ExpiresAt.After(c.now()) { + return InstallationToken{}, ErrInstallationTokenExpired + } + token.Principal.AppRegistrationID = c.registrationID + token.Principal.AppCredentialGeneration = c.credentialGeneration + + c.mu.Lock() + defer c.mu.Unlock() + if c.generations[installationID] != generation { + return InstallationToken{}, ErrInstallationTokenInvalidated + } + c.entries[key] = installationTokenCacheEntry{token: cloneInstallationToken(token)} + return token, nil + }) +} + +func (c *InstallationTokenCache) resolveRefreshResult( + key installationTokenCacheKey, + resolved singleflight.Result, +) (InstallationToken, error) { + if resolved.Err != nil { + if !errors.Is(resolved.Err, ErrInstallationTokenInvalidated) { + if token, ok := c.cached(key, 0); ok { + return token, nil + } + } + return InstallationToken{}, resolved.Err + } + token, ok := resolved.Val.(InstallationToken) + if !ok || token.Token == "" || !token.ExpiresAt.After(c.now()) { + return InstallationToken{}, ErrInstallationTokenExpired + } + return cloneInstallationToken(token), nil +} + +// Invalidate prevents cached or in-flight results for an installation from +// being reused after disconnect, suspension, revocation, or replacement. +func (c *InstallationTokenCache) Invalidate(installationID int64) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.generations[installationID]++ + for key := range c.entries { + if key.installationID == installationID { + delete(c.entries, key) + } + } +} + +func (c *InstallationTokenCache) generation(installationID int64) uint64 { + c.mu.RLock() + defer c.mu.RUnlock() + return c.generations[installationID] +} + +func (c *InstallationTokenCache) cached( + key installationTokenCacheKey, + refreshMargin time.Duration, +) (InstallationToken, bool) { + c.mu.RLock() + entry, ok := c.entries[key] + c.mu.RUnlock() + if !ok || entry.token.Token == "" || !entry.token.ExpiresAt.After(c.now().Add(refreshMargin)) { + return InstallationToken{}, false + } + return cloneInstallationToken(entry.token), true +} + +func canonicalPermissions(permissions InstallationPermissions) string { + keys := make([]string, 0, len(permissions)) + for name := range permissions { + keys = append(keys, name) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, name := range keys { + parts = append(parts, name+"="+string(permissions[name])) + } + return strings.Join(parts, ",") +} + +func canonicalRepositories(repositories []string) []string { + unique := make(map[string]struct{}, len(repositories)) + for _, repository := range repositories { + repository = strings.ToLower(strings.TrimSpace(repository)) + if repository != "" { + unique[repository] = struct{}{} + } + } + result := make([]string, 0, len(unique)) + for repository := range unique { + result = append(result, repository) + } + sort.Strings(result) + return result +} + +func clonePermissions(permissions InstallationPermissions) InstallationPermissions { + if permissions == nil { + return nil + } + cloned := make(InstallationPermissions, len(permissions)) + for name, level := range permissions { + cloned[name] = level + } + return cloned +} + +func cloneInstallationToken(token InstallationToken) InstallationToken { + token.Permissions = clonePermissions(token.Permissions) + return token +} diff --git a/apps/backend/internal/github/app_token_cache_test.go b/apps/backend/internal/github/app_token_cache_test.go new file mode 100644 index 0000000000..3c4da35cac --- /dev/null +++ b/apps/backend/internal/github/app_token_cache_test.go @@ -0,0 +1,277 @@ +package github + +import ( + "context" + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type stubInstallationTokenMinter struct { + calls atomic.Int64 + mint func(context.Context, int64, InstallationPermissions, []string) (InstallationToken, error) +} + +func (s *stubInstallationTokenMinter) MintInstallationToken( + ctx context.Context, + installationID int64, + permissions InstallationPermissions, + repositories []string, +) (InstallationToken, error) { + s.calls.Add(1) + return s.mint(ctx, installationID, permissions, repositories) +} + +func TestInstallationTokenCache_CoalescesAndRefreshes(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + release := make(chan struct{}) + minter := &stubInstallationTokenMinter{} + minter.mint = func(_ context.Context, installationID int64, permissions InstallationPermissions, _ []string) (InstallationToken, error) { + <-release + return InstallationToken{Token: "first", ExpiresAt: now.Add(time.Hour)}, nil + } + cache := NewInstallationTokenCache(minter) + cache.now = func() time.Time { return now } + permissions := InstallationPermissions{"contents": PermissionWrite} + + const callers = 20 + var wg sync.WaitGroup + var ready sync.WaitGroup + wg.Add(callers) + ready.Add(callers) + start := make(chan struct{}) + errs := make(chan error, callers) + for range callers { + go func() { + defer wg.Done() + ready.Done() + <-start + token, err := cache.Get(context.Background(), 42, permissions, nil) + if err == nil && token.Token != "first" { + err = errors.New("unexpected token") + } + errs <- err + }() + } + ready.Wait() + close(start) + close(release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("Get: %v", err) + } + } + if got := minter.calls.Load(); got != 1 { + t.Fatalf("mint calls = %d, want 1", got) + } + + minter.mint = func(_ context.Context, _ int64, _ InstallationPermissions, _ []string) (InstallationToken, error) { + return InstallationToken{Token: "second", ExpiresAt: now.Add(2 * time.Hour)}, nil + } + now = now.Add(56 * time.Minute) + token, err := cache.Get(context.Background(), 42, permissions, nil) + if err != nil { + t.Fatalf("refresh Get: %v", err) + } + if token.Token != "second" || minter.calls.Load() != 2 { + t.Fatalf("refresh token = %q, calls = %d", token.Token, minter.calls.Load()) + } +} + +func TestInstallationTokenCache_RefreshFailureUsesOnlyUnexpiredToken(t *testing.T) { + now := time.Now() + mintErr := errors.New("mint failed") + minter := &stubInstallationTokenMinter{} + minter.mint = func(_ context.Context, _ int64, _ InstallationPermissions, _ []string) (InstallationToken, error) { + if minter.calls.Load() == 1 { + return InstallationToken{Token: "first", ExpiresAt: now.Add(time.Hour)}, nil + } + return InstallationToken{}, mintErr + } + cache := NewInstallationTokenCache(minter) + cache.now = func() time.Time { return now } + first, err := cache.Get(context.Background(), 1, nil, nil) + if err != nil { + t.Fatalf("first Get: %v", err) + } + + now = now.Add(56 * time.Minute) + fallback, err := cache.Get(context.Background(), 1, nil, nil) + if err != nil || fallback.Token != first.Token { + t.Fatalf("valid fallback = %+v, %v", fallback, err) + } + + now = now.Add(5 * time.Minute) + if _, err := cache.Get(context.Background(), 1, nil, nil); !errors.Is(err, mintErr) { + t.Fatalf("expired fallback error = %v, want mint error", err) + } +} + +func TestInstallationTokenCache_InvalidateRejectsInflightMint(t *testing.T) { + now := time.Now() + started := make(chan struct{}) + release := make(chan struct{}) + minter := &stubInstallationTokenMinter{} + minter.mint = func(_ context.Context, _ int64, _ InstallationPermissions, _ []string) (InstallationToken, error) { + close(started) + <-release + return InstallationToken{Token: "stale", ExpiresAt: now.Add(time.Hour)}, nil + } + cache := NewInstallationTokenCache(minter) + cache.now = func() time.Time { return now } + result := make(chan error, 1) + go func() { + _, err := cache.Get(context.Background(), 1, nil, nil) + result <- err + }() + <-started + cache.Invalidate(1) + close(release) + if err := <-result; !errors.Is(err, ErrInstallationTokenInvalidated) { + t.Fatalf("in-flight Get error = %v, want invalidated", err) + } +} + +func TestInstallationTokenCache_PermissionScopeIsPartOfKey(t *testing.T) { + now := time.Now() + minter := &stubInstallationTokenMinter{} + minter.mint = func(_ context.Context, _ int64, _ InstallationPermissions, _ []string) (InstallationToken, error) { + return InstallationToken{Token: "token", ExpiresAt: now.Add(time.Hour)}, nil + } + cache := NewInstallationTokenCache(minter) + cache.now = func() time.Time { return now } + _, _ = cache.Get(context.Background(), 1, InstallationPermissions{"contents": PermissionRead}, nil) + _, _ = cache.Get(context.Background(), 1, InstallationPermissions{"contents": PermissionWrite}, nil) + _, _ = cache.Get(context.Background(), 2, InstallationPermissions{"contents": PermissionRead}, nil) + if got := minter.calls.Load(); got != 3 { + t.Fatalf("mint calls = %d, want 3 distinct cache keys", got) + } +} + +func TestInstallationTokenCache_RepositoryScopeIsPartOfKey(t *testing.T) { + now := time.Now() + minter := &stubInstallationTokenMinter{} + minter.mint = func(_ context.Context, _ int64, _ InstallationPermissions, repositories []string) (InstallationToken, error) { + return InstallationToken{Token: strings.Join(repositories, ","), ExpiresAt: now.Add(time.Hour)}, nil + } + cache := NewInstallationTokenCache(minter) + cache.now = func() time.Time { return now } + first, err := cache.Get(context.Background(), 1, nil, []string{"frontend"}) + if err != nil { + t.Fatal(err) + } + second, err := cache.Get(context.Background(), 1, nil, []string{"backend"}) + if err != nil { + t.Fatal(err) + } + again, err := cache.Get(context.Background(), 1, nil, []string{"frontend"}) + if err != nil { + t.Fatal(err) + } + if first.Token != "frontend" || second.Token != "backend" || again.Token != first.Token || minter.calls.Load() != 2 { + t.Fatalf("tokens = %q/%q/%q, calls = %d", first.Token, second.Token, again.Token, minter.calls.Load()) + } +} + +func TestInstallationTokenCache_NeverReturnsExpiredToken(t *testing.T) { + now := time.Now() + minter := &stubInstallationTokenMinter{} + minter.mint = func(_ context.Context, _ int64, _ InstallationPermissions, _ []string) (InstallationToken, error) { + return InstallationToken{Token: "expired", ExpiresAt: now}, nil + } + cache := NewInstallationTokenCache(minter) + cache.now = func() time.Time { return now } + if _, err := cache.Get(context.Background(), 1, nil, nil); !errors.Is(err, ErrInstallationTokenExpired) { + t.Fatalf("Get error = %v, want ErrInstallationTokenExpired", err) + } +} + +func TestInstallationTokenCache_InvalidateDropsCachedToken(t *testing.T) { + now := time.Now() + minter := &stubInstallationTokenMinter{} + minter.mint = func(_ context.Context, _ int64, _ InstallationPermissions, _ []string) (InstallationToken, error) { + call := minter.calls.Load() + return InstallationToken{Token: string(rune('a' + call)), ExpiresAt: now.Add(time.Hour)}, nil + } + cache := NewInstallationTokenCache(minter) + cache.now = func() time.Time { return now } + first, err := cache.Get(context.Background(), 1, nil, nil) + if err != nil { + t.Fatalf("first Get: %v", err) + } + cache.Invalidate(1) + second, err := cache.Get(context.Background(), 1, nil, nil) + if err != nil { + t.Fatalf("second Get: %v", err) + } + if first.Token == second.Token || minter.calls.Load() != 2 { + t.Fatalf("tokens = %q/%q, calls = %d", first.Token, second.Token, minter.calls.Load()) + } +} + +func TestInstallationTokenCache_ReturnedPermissionsCannotMutateCache(t *testing.T) { + now := time.Now() + minter := &stubInstallationTokenMinter{} + minter.mint = func(_ context.Context, _ int64, _ InstallationPermissions, _ []string) (InstallationToken, error) { + return InstallationToken{ + Token: "token", + ExpiresAt: now.Add(time.Hour), + Permissions: InstallationPermissions{"contents": PermissionRead}, + }, nil + } + cache := NewInstallationTokenCache(minter) + cache.now = func() time.Time { return now } + first, err := cache.Get(context.Background(), 1, nil, nil) + if err != nil { + t.Fatalf("first Get: %v", err) + } + first.Permissions["contents"] = PermissionWrite + second, err := cache.Get(context.Background(), 1, nil, nil) + if err != nil { + t.Fatalf("second Get: %v", err) + } + if second.Permissions["contents"] != PermissionRead { + t.Fatalf("cached permissions mutated to %q", second.Permissions["contents"]) + } +} + +func TestInstallationTokenCacheCarriesRegistrationIdentity(t *testing.T) { + now := time.Now() + minter := &stubInstallationTokenMinter{} + minter.mint = func(context.Context, int64, InstallationPermissions, []string) (InstallationToken, error) { + return InstallationToken{Token: "token", ExpiresAt: now.Add(time.Hour)}, nil + } + cache := NewAppInstallationTokenCache("registration-a", 9, minter) + cache.now = func() time.Time { return now } + token, err := cache.Get(context.Background(), 42, nil, nil) + if err != nil { + t.Fatal(err) + } + if token.Principal.AppRegistrationID != "registration-a" || + token.Principal.AppCredentialGeneration != 9 { + t.Fatalf("token principal = %+v", token.Principal) + } +} + +func TestInstallationTokenCacheSeparatesWorkspacesReusingRegistration(t *testing.T) { + now := time.Now() + minter := &stubInstallationTokenMinter{} + minter.mint = func(context.Context, int64, InstallationPermissions, []string) (InstallationToken, error) { + return InstallationToken{Token: "token", ExpiresAt: now.Add(time.Hour)}, nil + } + cache := NewAppInstallationTokenCache("registration-a", 9, minter) + cache.now = func() time.Time { return now } + for _, workspaceID := range []string{"workspace-a", "workspace-b"} { + if _, err := cache.GetForWorkspace(context.Background(), workspaceID, 42, nil, []string{"repo"}); err != nil { + t.Fatal(err) + } + } + if got := minter.calls.Load(); got != 2 { + t.Fatalf("mint calls = %d, want one isolated token per workspace", got) + } +} diff --git a/apps/backend/internal/github/auth_principal.go b/apps/backend/internal/github/auth_principal.go new file mode 100644 index 0000000000..c10d00c332 --- /dev/null +++ b/apps/backend/internal/github/auth_principal.go @@ -0,0 +1,67 @@ +package github + +import ( + "errors" + "time" +) + +const DefaultUserID = "default-user" + +// CredentialPurpose makes caller intent explicit so background automation +// cannot accidentally receive a user's personal credential. +type CredentialPurpose string + +const ( + CredentialPurposeAutomation CredentialPurpose = "automation" + CredentialPurposePersonalRead CredentialPurpose = "personal_read" + CredentialPurposePersonalWrite CredentialPurpose = "personal_write" + CredentialPurposeGitTransport CredentialPurpose = "git_transport" +) + +type AuthPrincipalKind string + +const ( + AuthPrincipalHuman AuthPrincipalKind = "human" + AuthPrincipalApp AuthPrincipalKind = "app" +) + +// AuthPrincipal describes the identity GitHub will attribute an operation to. +// It deliberately contains no credential material. +type AuthPrincipal struct { + Kind AuthPrincipalKind `json:"kind"` + Source ConnectionSource `json:"source"` + Login string `json:"login,omitempty"` + InstallationID int64 `json:"installation_id,omitempty"` + AppRegistrationID string `json:"app_registration_id,omitempty"` + AppCredentialGeneration int64 `json:"app_credential_generation,omitempty"` + WorkspaceID string `json:"workspace_id"` + UserID string `json:"user_id,omitempty"` +} + +type ResolveCredentialRequest struct { + WorkspaceID string + UserID string + Purpose CredentialPurpose + RepoOwner string + RepoName string +} + +type ResolvedCredential struct { + Client Client + Principal AuthPrincipal + Capabilities map[GitHubAppCapability]bool + CredentialGeneration int64 + AppRegistrationID string + AppCredentialGeneration int64 + ExpiresAt time.Time + RateTracker *RateTracker + credential string +} + +var ( + ErrGitHubWorkspaceRequired = errors.New("GitHub workspace is required") + ErrGitHubNotConfigured = errors.New("GitHub is not configured for this workspace") + ErrGitHubConnectionInvalid = errors.New("GitHub connection is not active") + ErrGitHubPersonalRequired = errors.New("personal GitHub connection is required") + ErrGitHubCapabilityDenied = errors.New("GitHub connection lacks a required capability") +) diff --git a/apps/backend/internal/github/auth_resolver.go b/apps/backend/internal/github/auth_resolver.go new file mode 100644 index 0000000000..ee531400ce --- /dev/null +++ b/apps/backend/internal/github/auth_resolver.go @@ -0,0 +1,547 @@ +package github + +import ( + "context" + "errors" + "fmt" + "runtime" + "strings" + "sync" + "time" +) + +const credentialCacheRefreshMargin = 5 * time.Minute + +var errCredentialResolutionInvalidated = errors.New("GitHub credential resolution invalidated") + +type workspaceConnectionReader interface { + GetWorkspaceConnection(ctx context.Context, workspaceID string) (*WorkspaceConnection, error) + GetUserConnection(ctx context.Context, workspaceID, userID string) (*UserConnection, error) +} + +type authSecretReader interface { + Reveal(ctx context.Context, id string) (string, error) +} + +type installationCredentialProvider interface { + ResolveInstallation( + ctx context.Context, + connection *WorkspaceConnection, + req ResolveCredentialRequest, + ) (*ResolvedCredential, error) +} + +type appCredentialGenerationProvider interface { + AppCredentialGeneration(registrationID string) (int64, bool) +} + +type userCredentialProvider interface { + ResolveUser( + ctx context.Context, + connection *UserConnection, + req ResolveCredentialRequest, + ) (*ResolvedCredential, error) +} + +type automationCredentialProvider interface { + ResolveAutomation( + ctx context.Context, + connection *WorkspaceConnection, + req ResolveCredentialRequest, + ) (*ResolvedCredential, error) +} + +type legacyCredentialFactory func(ctx context.Context) (Client, string, error) +type ghAccountTokenResolver func(ctx context.Context, host, login string) (string, error) + +type credentialCacheKey struct { + workspaceID string + source ConnectionSource + generation int64 + appRegistrationID string + appCredentialGeneration int64 + purpose CredentialPurpose + repoOwner string + repoName string +} + +type credentialCacheEpoch struct { + all uint64 + workspace uint64 +} + +// CredentialResolver owns credential selection for all GitHub operations. +// Its cache is keyed by workspace and generation so replacement or revocation +// cannot reuse a client created for another principal. +type CredentialResolver struct { + connections workspaceConnectionReader + secrets authSecretReader + app installationCredentialProvider + users userCredentialProvider + automation automationCredentialProvider + legacy legacyCredentialFactory + ghToken ghAccountTokenResolver + now func() time.Time + + mu sync.Mutex + cache map[credentialCacheKey]*ResolvedCredential + allEpoch uint64 + workspaceEpochs map[string]uint64 +} + +func NewCredentialResolver(connections workspaceConnectionReader, secrets authSecretReader) *CredentialResolver { + return &CredentialResolver{ + connections: connections, + secrets: secrets, + legacy: func(ctx context.Context) (Client, string, error) { + return nil, AuthMethodNone, ErrGitHubNotConfigured + }, + ghToken: ResolveGHAccountToken, + now: time.Now, + cache: make(map[credentialCacheKey]*ResolvedCredential), + workspaceEpochs: make(map[string]uint64), + } +} + +func (r *CredentialResolver) SetLegacyFactory(factory legacyCredentialFactory) { + if factory != nil { + r.legacy = factory + } +} + +func (r *CredentialResolver) SetInstallationProvider(provider installationCredentialProvider) { + r.app = provider +} + +func (r *CredentialResolver) SetUserProvider(provider userCredentialProvider) { + r.users = provider +} + +func (r *CredentialResolver) SetAutomationProvider(provider automationCredentialProvider) { + r.automation = provider +} + +func (r *CredentialResolver) Resolve(ctx context.Context, req ResolveCredentialRequest) (*ResolvedCredential, error) { + if req.WorkspaceID == "" { + return nil, ErrGitHubWorkspaceRequired + } + if !validCredentialPurpose(req.Purpose) { + return nil, fmt.Errorf("invalid GitHub credential purpose %q", req.Purpose) + } + for { + epoch := r.epoch(req.WorkspaceID) + resolved, err := r.resolveAtEpoch(ctx, req, epoch) + if !r.epochCurrent(req.WorkspaceID, epoch) || errors.Is(err, errCredentialResolutionInvalidated) { + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + runtime.Gosched() + continue + } + return resolved, err + } +} + +func (r *CredentialResolver) resolveAtEpoch( + ctx context.Context, + req ResolveCredentialRequest, + epoch credentialCacheEpoch, +) (*ResolvedCredential, error) { + connection, err := r.connections.GetWorkspaceConnection(ctx, req.WorkspaceID) + if err != nil { + return nil, fmt.Errorf("load GitHub workspace connection: %w", err) + } + if connection == nil { + return nil, ErrGitHubNotConfigured + } + + if req.Purpose == CredentialPurposePersonalRead || req.Purpose == CredentialPurposePersonalWrite { + if personal, resolveErr := r.resolvePersonal(ctx, req, connection); personal != nil || resolveErr != nil { + return personal, resolveErr + } + } + return r.resolveAutomation(ctx, req, connection, epoch) +} + +func validCredentialPurpose(purpose CredentialPurpose) bool { + switch purpose { + case CredentialPurposeAutomation, CredentialPurposePersonalRead, + CredentialPurposePersonalWrite, CredentialPurposeGitTransport: + return true + default: + return false + } +} + +func (r *CredentialResolver) resolvePersonal( + ctx context.Context, + req ResolveCredentialRequest, + automation *WorkspaceConnection, +) (*ResolvedCredential, error) { + personal, err := r.resolveConfiguredPersonal(ctx, req) + if personal != nil { + return personal, err + } + if err != nil && (req.Purpose != CredentialPurposePersonalWrite || !errors.Is(err, ErrGitHubConnectionInvalid)) { + return nil, err + } + + // A human PAT/CLI automation identity can supply authenticated-viewer + // semantics. An App installation cannot impersonate a person. + if automation.Source == ConnectionSourcePAT || automation.Source == ConnectionSourceGHCLI || automation.Source == ConnectionSourceLegacyShared { + return nil, nil + } + if req.Purpose == CredentialPurposePersonalWrite { + // Manual mutations may intentionally fall back to the App. The returned + // principal lets the caller disclose that attribution. + return nil, nil + } + return nil, ErrGitHubPersonalRequired +} + +func (r *CredentialResolver) resolveConfiguredPersonal( + ctx context.Context, + req ResolveCredentialRequest, +) (*ResolvedCredential, error) { + if req.UserID == "" || r.users == nil { + return nil, nil + } + connection, err := r.connections.GetUserConnection(ctx, req.WorkspaceID, req.UserID) + if err != nil { + return nil, fmt.Errorf("load personal GitHub connection: %w", err) + } + if connection == nil { + return nil, nil + } + if connection.Status != ConnectionStatusActive { + return nil, ErrGitHubConnectionInvalid + } + resolved, err := r.users.ResolveUser(ctx, connection, req) + if err != nil { + return nil, err + } + resolved.Principal.WorkspaceID = req.WorkspaceID + resolved.Principal.UserID = req.UserID + resolved.CredentialGeneration = connection.CredentialGeneration + return resolved, nil +} + +func (r *CredentialResolver) resolveAutomation( + ctx context.Context, + req ResolveCredentialRequest, + connection *WorkspaceConnection, + epoch credentialCacheEpoch, +) (*ResolvedCredential, error) { + if connection.Status != ConnectionStatusActive { + return nil, ErrGitHubConnectionInvalid + } + if connection.Source == ConnectionSourceGitHubAppInstallation && + strings.TrimSpace(connection.AppRegistrationID) == "" { + return nil, ErrGitHubNotConfigured + } + key, err := r.automationCacheKey(connection, req) + if err != nil { + return nil, err + } + if cached := r.cached(key); cached != nil { + return cached, nil + } + + resolved, err := r.resolveAutomationSource(ctx, connection, req) + if err != nil { + return nil, err + } + if resolved == nil || resolved.Client == nil { + return nil, ErrGitHubNotConfigured + } + resolved.Principal.WorkspaceID = req.WorkspaceID + resolved.CredentialGeneration = connection.CredentialGeneration + if err := applyAppCredentialMetadata(connection, resolved, &key); err != nil { + return nil, err + } + if !resolved.ExpiresAt.IsZero() && !resolved.ExpiresAt.After(r.now()) { + return nil, ErrInstallationTokenExpired + } + if !r.storeCached(key, resolved, epoch) { + return nil, errCredentialResolutionInvalidated + } + return resolved, nil +} + +func (r *CredentialResolver) automationCacheKey( + connection *WorkspaceConnection, + req ResolveCredentialRequest, +) (credentialCacheKey, error) { + key := credentialCacheKey{ + workspaceID: req.WorkspaceID, source: connection.Source, + generation: connection.CredentialGeneration, appRegistrationID: connection.AppRegistrationID, + purpose: req.Purpose, repoOwner: strings.ToLower(strings.TrimSpace(req.RepoOwner)), + repoName: strings.ToLower(strings.TrimSpace(req.RepoName)), + } + if connection.Source != ConnectionSourceGitHubAppInstallation { + return key, nil + } + provider, ok := r.app.(appCredentialGenerationProvider) + if !ok { + return key, nil + } + generation, found := provider.AppCredentialGeneration(connection.AppRegistrationID) + if !found { + return credentialCacheKey{}, ErrGitHubNotConfigured + } + key.appCredentialGeneration = generation + return key, nil +} + +func applyAppCredentialMetadata( + connection *WorkspaceConnection, + resolved *ResolvedCredential, + key *credentialCacheKey, +) error { + if connection.Source != ConnectionSourceGitHubAppInstallation { + return nil + } + if resolved.AppRegistrationID == "" { + resolved.AppRegistrationID = connection.AppRegistrationID + } + if resolved.Principal.AppRegistrationID == "" { + resolved.Principal.AppRegistrationID = resolved.AppRegistrationID + } + if resolved.Principal.AppRegistrationID != connection.AppRegistrationID || + resolved.AppRegistrationID != connection.AppRegistrationID { + return ErrGitHubNotConfigured + } + resolved.Principal.AppCredentialGeneration = resolved.AppCredentialGeneration + key.appCredentialGeneration = resolved.AppCredentialGeneration + return nil +} + +func (r *CredentialResolver) resolveAutomationSource( + ctx context.Context, + connection *WorkspaceConnection, + req ResolveCredentialRequest, +) (*ResolvedCredential, error) { + if r.automation != nil { + return r.automation.ResolveAutomation(ctx, connection, req) + } + switch connection.Source { + case ConnectionSourcePAT: + return r.resolvePAT(ctx, connection) + case ConnectionSourceGHCLI: + return r.resolveGHCLI(ctx, connection) + case ConnectionSourceGitHubAppInstallation: + if r.app == nil { + return nil, ErrGitHubNotConfigured + } + return r.app.ResolveInstallation(ctx, connection, req) + case ConnectionSourceLegacyShared: + return r.resolveLegacy(ctx, connection) + default: + return nil, fmt.Errorf("unsupported GitHub connection source %q", connection.Source) + } +} + +func (r *CredentialResolver) resolvePAT(ctx context.Context, connection *WorkspaceConnection) (*ResolvedCredential, error) { + if r.secrets == nil { + return nil, ErrGitHubNotConfigured + } + token, err := r.secrets.Reveal(ctx, WorkspacePATSecretKey(connection.WorkspaceID)) + if err != nil { + return nil, fmt.Errorf("reveal workspace GitHub PAT: %w", err) + } + tracker := NewRateTracker(nil, nil) + client := NewTokenClient(token, TokenPrincipal{ + Kind: TokenCredentialPAT, + PrincipalID: "login:" + connection.Login, + Login: connection.Login, + }).WithRateTracker(tracker) + return &ResolvedCredential{ + Client: client, + Capabilities: allTokenCapabilities(), + credential: token, + Principal: AuthPrincipal{ + Kind: AuthPrincipalHuman, + Source: ConnectionSourcePAT, + Login: connection.Login, + }, + RateTracker: tracker, + }, nil +} + +func (r *CredentialResolver) resolveGHCLI(ctx context.Context, connection *WorkspaceConnection) (*ResolvedCredential, error) { + token, err := r.ghToken(ctx, connection.GitHubHost, connection.Login) + if err != nil { + return nil, err + } + tracker := NewRateTracker(nil, nil) + client := NewTokenClient(token, TokenPrincipal{ + Kind: TokenCredentialCLI, + PrincipalID: "login:" + connection.Login, + Login: connection.Login, + }).WithRateTracker(tracker) + return &ResolvedCredential{ + Client: client, + Capabilities: allTokenCapabilities(), + credential: token, + Principal: AuthPrincipal{ + Kind: AuthPrincipalHuman, + Source: ConnectionSourceGHCLI, + Login: connection.Login, + }, + RateTracker: tracker, + }, nil +} + +func (r *CredentialResolver) resolveLegacy(ctx context.Context, connection *WorkspaceConnection) (*ResolvedCredential, error) { + client, method, err := r.legacy(ctx) + if err != nil { + return nil, err + } + if client == nil || method == AuthMethodNone { + return nil, ErrGitHubNotConfigured + } + login, _ := client.GetAuthenticatedUser(ctx) + return &ResolvedCredential{ + Client: client, + Capabilities: allTokenCapabilities(), + Principal: AuthPrincipal{ + Kind: AuthPrincipalHuman, + Source: ConnectionSourceLegacyShared, + Login: login, + }, + RateTracker: NewRateTracker(nil, nil), + }, nil +} + +func (r *CredentialResolver) cached(key credentialCacheKey) *ResolvedCredential { + r.mu.Lock() + defer r.mu.Unlock() + resolved := r.cache[key] + if resolved == nil { + return nil + } + if !resolved.ExpiresAt.IsZero() && !resolved.ExpiresAt.After(r.now().Add(credentialCacheRefreshMargin)) { + delete(r.cache, key) + return nil + } + return cloneResolvedCredential(resolved) +} + +func (r *CredentialResolver) storeCached( + key credentialCacheKey, + resolved *ResolvedCredential, + epoch credentialCacheEpoch, +) bool { + r.mu.Lock() + defer r.mu.Unlock() + if !r.epochCurrentLocked(key.workspaceID, epoch) { + return false + } + for existing := range r.cache { + if existing.workspaceID == key.workspaceID && existing.generation < key.generation { + delete(r.cache, existing) + } + } + r.cache[key] = cloneResolvedCredential(resolved) + return true +} + +func cloneResolvedCredential(resolved *ResolvedCredential) *ResolvedCredential { + if resolved == nil { + return nil + } + cloned := *resolved + if resolved.Capabilities != nil { + cloned.Capabilities = make(map[GitHubAppCapability]bool, len(resolved.Capabilities)) + for capability, allowed := range resolved.Capabilities { + cloned.Capabilities[capability] = allowed + } + } + return &cloned +} + +var allGitHubCapabilities = []GitHubAppCapability{ + CapabilityRepositoryRead, + CapabilityGitRead, + CapabilityGitWrite, + CapabilityPullRequestRead, + CapabilityPullRequestWrite, + CapabilityIssueRead, + CapabilityIssueWrite, + CapabilityChecksRead, + CapabilityStatusesRead, + CapabilityActionsRead, + CapabilityBranchProtectionRead, + CapabilityMembersRead, + CapabilityWorkflowsWrite, +} + +func allTokenCapabilities() map[GitHubAppCapability]bool { + capabilities := make(map[GitHubAppCapability]bool, len(allGitHubCapabilities)) + for _, capability := range allGitHubCapabilities { + capabilities[capability] = true + } + return capabilities +} + +func (r *CredentialResolver) InvalidateWorkspace(workspaceID string) { + r.mu.Lock() + defer r.mu.Unlock() + r.workspaceEpochs[workspaceID]++ + for key := range r.cache { + if key.workspaceID == workspaceID { + delete(r.cache, key) + } + } +} + +func (r *CredentialResolver) InvalidateAll() { + r.mu.Lock() + defer r.mu.Unlock() + r.allEpoch++ + clear(r.cache) +} + +func (r *CredentialResolver) InvalidateAppRegistration(registrationID string) { + if strings.TrimSpace(registrationID) == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + for key := range r.cache { + if key.appRegistrationID == registrationID { + delete(r.cache, key) + } + } +} + +func (r *CredentialResolver) appCredentialGeneration(registrationID string) (int64, error) { + if strings.TrimSpace(registrationID) == "" || r.app == nil { + return 0, ErrGitHubNotConfigured + } + provider, ok := r.app.(appCredentialGenerationProvider) + if !ok { + return 0, ErrGitHubNotConfigured + } + generation, found := provider.AppCredentialGeneration(registrationID) + if !found || generation <= 0 { + return 0, ErrGitHubNotConfigured + } + return generation, nil +} + +func (r *CredentialResolver) epoch(workspaceID string) credentialCacheEpoch { + r.mu.Lock() + defer r.mu.Unlock() + return credentialCacheEpoch{all: r.allEpoch, workspace: r.workspaceEpochs[workspaceID]} +} + +func (r *CredentialResolver) epochCurrent(workspaceID string, expected credentialCacheEpoch) bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.epochCurrentLocked(workspaceID, expected) +} + +func (r *CredentialResolver) epochCurrentLocked(workspaceID string, expected credentialCacheEpoch) bool { + return r.allEpoch == expected.all && r.workspaceEpochs[workspaceID] == expected.workspace +} diff --git a/apps/backend/internal/github/auth_resolver_test.go b/apps/backend/internal/github/auth_resolver_test.go new file mode 100644 index 0000000000..65d1e91f1b --- /dev/null +++ b/apps/backend/internal/github/auth_resolver_test.go @@ -0,0 +1,536 @@ +package github + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +type fakeConnectionReader struct { + workspaces map[string]*WorkspaceConnection + users map[string]*UserConnection +} + +func (f *fakeConnectionReader) GetWorkspaceConnection(_ context.Context, workspaceID string) (*WorkspaceConnection, error) { + return f.workspaces[workspaceID], nil +} + +func (f *fakeConnectionReader) GetUserConnection(_ context.Context, workspaceID, userID string) (*UserConnection, error) { + return f.users[workspaceID+":"+userID], nil +} + +type fakeAuthSecrets map[string]string + +type repoScopedInstallationProvider struct { + clients map[string]Client + calls int +} + +type registrationInstallationProvider struct { + calls map[string]int +} + +func (p *registrationInstallationProvider) ResolveInstallation( + _ context.Context, + connection *WorkspaceConnection, + _ ResolveCredentialRequest, +) (*ResolvedCredential, error) { + p.calls[connection.AppRegistrationID]++ + return &ResolvedCredential{ + Client: NewMockClient(), + Principal: AuthPrincipal{ + Kind: AuthPrincipalApp, Source: ConnectionSourceGitHubAppInstallation, + AppRegistrationID: connection.AppRegistrationID, + }, + AppRegistrationID: connection.AppRegistrationID, + AppCredentialGeneration: 7, + }, nil +} + +type expiringInstallationProvider struct { + now func() time.Time + lifetime time.Duration + calls int +} + +type synchronizedConnectionReader struct { + mu sync.Mutex + connection WorkspaceConnection +} + +func (r *synchronizedConnectionReader) GetWorkspaceConnection( + _ context.Context, + _ string, +) (*WorkspaceConnection, error) { + r.mu.Lock() + defer r.mu.Unlock() + connection := r.connection + if r.connection.InstallationID != nil { + installationID := *r.connection.InstallationID + connection.InstallationID = &installationID + } + return &connection, nil +} + +func (r *synchronizedConnectionReader) GetUserConnection( + context.Context, + string, + string, +) (*UserConnection, error) { + return nil, nil +} + +func (r *synchronizedConnectionReader) replace(connection WorkspaceConnection) { + r.mu.Lock() + r.connection = connection + r.mu.Unlock() +} + +type generationBarrierProvider struct { + oldStarted chan struct{} + releaseOld chan struct{} + oldClient Client + newClient Client + startOnce sync.Once + mu sync.Mutex + calls map[int64]int +} + +func (p *generationBarrierProvider) ResolveInstallation( + ctx context.Context, + connection *WorkspaceConnection, + _ ResolveCredentialRequest, +) (*ResolvedCredential, error) { + p.mu.Lock() + p.calls[connection.CredentialGeneration]++ + p.mu.Unlock() + client := p.newClient + if connection.CredentialGeneration == 1 { + client = p.oldClient + p.startOnce.Do(func() { close(p.oldStarted) }) + select { + case <-p.releaseOld: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return &ResolvedCredential{ + Client: client, + Principal: AuthPrincipal{Kind: AuthPrincipalApp, Source: ConnectionSourceGitHubAppInstallation}, + }, nil +} + +func (p *expiringInstallationProvider) ResolveInstallation( + _ context.Context, + _ *WorkspaceConnection, + _ ResolveCredentialRequest, +) (*ResolvedCredential, error) { + p.calls++ + return &ResolvedCredential{ + Client: NewMockClient(), + Principal: AuthPrincipal{Kind: AuthPrincipalApp}, + ExpiresAt: p.now().Add(p.lifetime), + }, nil +} + +func (p *repoScopedInstallationProvider) ResolveInstallation( + _ context.Context, + _ *WorkspaceConnection, + req ResolveCredentialRequest, +) (*ResolvedCredential, error) { + p.calls++ + client := NewMockClient() + p.clients[req.RepoOwner+"/"+req.RepoName] = client + return &ResolvedCredential{ + Client: client, + Principal: AuthPrincipal{Kind: AuthPrincipalApp, Source: ConnectionSourceGitHubAppInstallation}, + }, nil +} + +func (f fakeAuthSecrets) Reveal(_ context.Context, id string) (string, error) { + value, ok := f[id] + if !ok { + return "", errors.New("missing secret") + } + return value, nil +} + +func TestCredentialResolverIsolatesWorkspacePATs(t *testing.T) { + connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + "personal": {WorkspaceID: "personal", Source: ConnectionSourcePAT, Login: "alice", Status: ConnectionStatusActive, CredentialGeneration: 1}, + "work": {WorkspaceID: "work", Source: ConnectionSourcePAT, Login: "alice-work", Status: ConnectionStatusActive, CredentialGeneration: 1}, + }} + resolver := NewCredentialResolver(connections, fakeAuthSecrets{ + WorkspacePATSecretKey("personal"): "pat-personal", + WorkspacePATSecretKey("work"): "pat-work", + }) + + personal, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{WorkspaceID: "personal", Purpose: CredentialPurposeAutomation}) + if err != nil { + t.Fatalf("resolve personal: %v", err) + } + work, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{WorkspaceID: "work", Purpose: CredentialPurposeAutomation}) + if err != nil { + t.Fatalf("resolve work: %v", err) + } + if personal.Client == work.Client || personal.RateTracker == work.RateTracker { + t.Fatal("workspace credentials share client or rate tracker") + } + if personal.Principal.Login != "alice" || work.Principal.Login != "alice-work" { + t.Fatalf("principals = %+v / %+v", personal.Principal, work.Principal) + } +} + +func TestCredentialResolverNeverFallsBackForDisconnectedWorkspace(t *testing.T) { + resolver := NewCredentialResolver(&fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{}}, nil) + resolver.SetLegacyFactory(func(context.Context) (Client, string, error) { + t.Fatal("legacy factory called for disconnected workspace") + return nil, "", nil + }) + _, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{WorkspaceID: "new", Purpose: CredentialPurposeAutomation}) + if !errors.Is(err, ErrGitHubNotConfigured) { + t.Fatalf("error = %v, want ErrGitHubNotConfigured", err) + } +} + +func TestAuthResolverKeysAppCredentialsByRegistration(t *testing.T) { + installationID := int64(42) + connection := &WorkspaceConnection{ + WorkspaceID: "work", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, AppRegistrationID: "registration-a", + Status: ConnectionStatusActive, CredentialGeneration: 1, + } + reader := &synchronizedConnectionReader{connection: *connection} + provider := ®istrationInstallationProvider{calls: make(map[string]int)} + resolver := NewCredentialResolver(reader, nil) + resolver.SetInstallationProvider(provider) + + first, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{ + WorkspaceID: "work", Purpose: CredentialPurposeAutomation, + }) + if err != nil { + t.Fatal(err) + } + connection.AppRegistrationID = "registration-b" + reader.replace(*connection) + second, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{ + WorkspaceID: "work", Purpose: CredentialPurposeAutomation, + }) + if err != nil { + t.Fatal(err) + } + if first.Principal.AppRegistrationID != "registration-a" || + second.Principal.AppRegistrationID != "registration-b" { + t.Fatalf("principals = %+v / %+v", first.Principal, second.Principal) + } + if provider.calls["registration-a"] != 1 || provider.calls["registration-b"] != 1 { + t.Fatalf("provider calls = %#v", provider.calls) + } +} + +func TestAuthResolverAppConnectionRequiresRegistrationID(t *testing.T) { + installationID := int64(42) + resolver := NewCredentialResolver(&fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + "work": { + WorkspaceID: "work", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, Status: ConnectionStatusActive, + CredentialGeneration: 1, + }, + }}, nil) + resolver.SetInstallationProvider(®istrationInstallationProvider{calls: make(map[string]int)}) + _, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{ + WorkspaceID: "work", Purpose: CredentialPurposeAutomation, + }) + if !errors.Is(err, ErrGitHubNotConfigured) { + t.Fatalf("error = %v, want ErrGitHubNotConfigured", err) + } +} + +func TestCredentialResolverUsesLegacyOnlyForMigratedSource(t *testing.T) { + connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + "old": {WorkspaceID: "old", Source: ConnectionSourceLegacyShared, Status: ConnectionStatusActive, CredentialGeneration: 1}, + }} + resolver := NewCredentialResolver(connections, nil) + legacy := NewMockClient() + legacy.SetUser("legacy-user") + resolver.SetLegacyFactory(func(context.Context) (Client, string, error) { return legacy, AuthMethodPAT, nil }) + + resolved, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{WorkspaceID: "old", Purpose: CredentialPurposeAutomation}) + if err != nil { + t.Fatalf("resolve legacy: %v", err) + } + if resolved.Principal.Source != ConnectionSourceLegacyShared || resolved.Principal.Login != "legacy-user" { + t.Fatalf("principal = %+v", resolved.Principal) + } +} + +func TestCredentialResolverNamedCLIUsesSelectedLogin(t *testing.T) { + connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + "work": {WorkspaceID: "work", Source: ConnectionSourceGHCLI, GitHubHost: "github.com", Login: "work-user", Status: ConnectionStatusActive, CredentialGeneration: 2}, + }} + resolver := NewCredentialResolver(connections, nil) + var gotHost, gotLogin string + resolver.ghToken = func(_ context.Context, host, login string) (string, error) { + gotHost, gotLogin = host, login + return "cli-token", nil + } + + resolved, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{WorkspaceID: "work", Purpose: CredentialPurposeAutomation}) + if err != nil { + t.Fatalf("resolve CLI: %v", err) + } + if gotHost != "github.com" || gotLogin != "work-user" { + t.Fatalf("selected account = %s@%s", gotLogin, gotHost) + } + if resolved.Principal.Source != ConnectionSourceGHCLI { + t.Fatalf("principal = %+v", resolved.Principal) + } +} + +func TestCredentialResolverAppRequiresPersonalIdentityForMyGitHub(t *testing.T) { + installationID := int64(42) + connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + "company": {WorkspaceID: "company", Source: ConnectionSourceGitHubAppInstallation, InstallationID: &installationID, AppRegistrationID: "registration-test", Status: ConnectionStatusActive, CredentialGeneration: 1}, + }} + resolver := NewCredentialResolver(connections, nil) + _, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{ + WorkspaceID: "company", UserID: "default-user", Purpose: CredentialPurposePersonalRead, + }) + if !errors.Is(err, ErrGitHubPersonalRequired) { + t.Fatalf("error = %v, want ErrGitHubPersonalRequired", err) + } +} + +func TestCredentialResolverInvalidPersonalFallsBackToAppOnlyForManualWrites(t *testing.T) { + installationID := int64(42) + connections := &fakeConnectionReader{ + workspaces: map[string]*WorkspaceConnection{ + "company": { + WorkspaceID: "company", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, AppRegistrationID: "registration-test", + Status: ConnectionStatusActive, CredentialGeneration: 1, + }, + }, + users: map[string]*UserConnection{ + "company:default-user": { + WorkspaceID: "company", UserID: "default-user", Login: "octocat", + Status: ConnectionStatusRevoked, CredentialGeneration: 2, + }, + }, + } + provider := &repoScopedInstallationProvider{clients: make(map[string]Client)} + resolver := NewCredentialResolver(connections, nil) + resolver.SetInstallationProvider(provider) + resolver.SetUserProvider(fixedUserCredentialProvider{client: NewMockClient()}) + + resolved, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{ + WorkspaceID: "company", UserID: "default-user", Purpose: CredentialPurposePersonalWrite, + }) + if err != nil { + t.Fatalf("manual write resolution: %v", err) + } + if resolved.Principal.Kind != AuthPrincipalApp || resolved.Principal.Source != ConnectionSourceGitHubAppInstallation { + t.Fatalf("manual write principal = %+v, want App installation", resolved.Principal) + } + + _, err = resolver.Resolve(context.Background(), ResolveCredentialRequest{ + WorkspaceID: "company", UserID: "default-user", Purpose: CredentialPurposePersonalRead, + }) + if !errors.Is(err, ErrGitHubConnectionInvalid) { + t.Fatalf("personal read error = %v, want reconnect-required invalid connection", err) + } +} + +func TestCredentialResolverGenerationSeparatesCachedClients(t *testing.T) { + connection := &WorkspaceConnection{WorkspaceID: "work", Source: ConnectionSourcePAT, Login: "first", Status: ConnectionStatusActive, CredentialGeneration: 1} + connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{"work": connection}} + resolver := NewCredentialResolver(connections, fakeAuthSecrets{WorkspacePATSecretKey("work"): "pat"}) + first, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{WorkspaceID: "work", Purpose: CredentialPurposeAutomation}) + if err != nil { + t.Fatal(err) + } + connection.CredentialGeneration = 2 + connection.Login = "second" + second, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{WorkspaceID: "work", Purpose: CredentialPurposeAutomation}) + if err != nil { + t.Fatal(err) + } + if first.Client == second.Client || second.Principal.Login != "second" { + t.Fatalf("generation did not replace client: first=%p second=%p principal=%+v", first.Client, second.Client, second.Principal) + } +} + +func TestCredentialResolverSeparatesRepositoryScopedAppTokens(t *testing.T) { + installationID := int64(42) + connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + "company": { + WorkspaceID: "company", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, AppRegistrationID: "registration-test", + Status: ConnectionStatusActive, CredentialGeneration: 1, + }, + }} + provider := &repoScopedInstallationProvider{clients: make(map[string]Client)} + resolver := NewCredentialResolver(connections, nil) + resolver.SetInstallationProvider(provider) + first, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{ + WorkspaceID: "company", Purpose: CredentialPurposeGitTransport, + RepoOwner: "acme", RepoName: "frontend", + }) + if err != nil { + t.Fatal(err) + } + second, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{ + WorkspaceID: "company", Purpose: CredentialPurposeGitTransport, + RepoOwner: "acme", RepoName: "backend", + }) + if err != nil { + t.Fatal(err) + } + again, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{ + WorkspaceID: "company", Purpose: CredentialPurposeGitTransport, + RepoOwner: "acme", RepoName: "frontend", + }) + if err != nil { + t.Fatal(err) + } + if first.Client == second.Client || again.Client != first.Client || provider.calls != 2 { + t.Fatalf("clients = %p/%p/%p, provider calls = %d", first.Client, second.Client, again.Client, provider.calls) + } +} + +func TestCredentialResolverRefreshesCachedCredentialBeforeExpiry(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + installationID := int64(42) + connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + "company": { + WorkspaceID: "company", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, AppRegistrationID: "registration-test", + Status: ConnectionStatusActive, CredentialGeneration: 1, + }, + }} + provider := &expiringInstallationProvider{now: func() time.Time { return now }, lifetime: 10 * time.Minute} + resolver := NewCredentialResolver(connections, nil) + resolver.now = func() time.Time { return now } + resolver.SetInstallationProvider(provider) + request := ResolveCredentialRequest{ + WorkspaceID: "company", Purpose: CredentialPurposeAutomation, + RepoOwner: "acme", RepoName: "backend", + } + + first, err := resolver.Resolve(context.Background(), request) + if err != nil { + t.Fatal(err) + } + now = now.Add(6 * time.Minute) + second, err := resolver.Resolve(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if first.Client == second.Client || provider.calls != 2 { + t.Fatalf("clients = %p/%p, provider calls = %d", first.Client, second.Client, provider.calls) + } +} + +func TestCredentialResolverRejectsNewlyExpiredCredential(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + installationID := int64(42) + connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + "company": { + WorkspaceID: "company", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, AppRegistrationID: "registration-test", + Status: ConnectionStatusActive, CredentialGeneration: 1, + }, + }} + provider := &expiringInstallationProvider{now: func() time.Time { return now }, lifetime: -time.Minute} + resolver := NewCredentialResolver(connections, nil) + resolver.now = func() time.Time { return now } + resolver.SetInstallationProvider(provider) + + resolved, err := resolver.Resolve(context.Background(), ResolveCredentialRequest{ + WorkspaceID: "company", Purpose: CredentialPurposeAutomation, + }) + if resolved != nil || !errors.Is(err, ErrInstallationTokenExpired) { + t.Fatalf("Resolve() = %+v, %v; want nil, ErrInstallationTokenExpired", resolved, err) + } +} + +func TestCredentialResolverInvalidationRejectsInflightOldGeneration(t *testing.T) { + for _, test := range []struct { + name string + resolveNewFirst bool + }{ + {name: "replacement resolution finishes before old resolution", resolveNewFirst: true}, + {name: "old resolution finishes before replacement caller", resolveNewFirst: false}, + } { + t.Run(test.name, func(t *testing.T) { + installationID := int64(42) + oldConnection := WorkspaceConnection{ + WorkspaceID: "company", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, AppRegistrationID: "registration-test", + Status: ConnectionStatusActive, CredentialGeneration: 1, + } + newConnection := oldConnection + newConnection.CredentialGeneration = 2 + connections := &synchronizedConnectionReader{connection: oldConnection} + provider := &generationBarrierProvider{ + oldStarted: make(chan struct{}), releaseOld: make(chan struct{}), + oldClient: NewMockClient(), newClient: NewMockClient(), calls: make(map[int64]int), + } + resolver := NewCredentialResolver(connections, nil) + resolver.SetInstallationProvider(provider) + request := ResolveCredentialRequest{WorkspaceID: "company", Purpose: CredentialPurposeAutomation} + oldResult := make(chan *ResolvedCredential, 1) + oldErr := make(chan error, 1) + go func() { + resolved, err := resolver.Resolve(context.Background(), request) + oldResult <- resolved + oldErr <- err + }() + <-provider.oldStarted + + connections.replace(newConnection) + resolver.InvalidateWorkspace("company") + var replacement *ResolvedCredential + var err error + if test.resolveNewFirst { + replacement, err = resolver.Resolve(context.Background(), request) + if err != nil { + t.Fatal(err) + } + close(provider.releaseOld) + } else { + close(provider.releaseOld) + replacement = <-oldResult + if err = <-oldErr; err != nil { + t.Fatal(err) + } + } + + var old *ResolvedCredential + if test.resolveNewFirst { + old = <-oldResult + if err = <-oldErr; err != nil { + t.Fatal(err) + } + } else { + old = replacement + replacement, err = resolver.Resolve(context.Background(), request) + if err != nil { + t.Fatal(err) + } + } + if old == nil || replacement == nil || old.Client != provider.newClient || + replacement.Client != provider.newClient { + t.Fatalf("old result = %+v, replacement = %+v", old, replacement) + } + provider.mu.Lock() + generationOneCalls := provider.calls[1] + provider.mu.Unlock() + if generationOneCalls != 1 { + t.Fatalf("generation 1 provider calls = %d, want 1", generationOneCalls) + } + }) + } +} diff --git a/apps/backend/internal/github/branch_protection.go b/apps/backend/internal/github/branch_protection.go index 7abfa70ff6..9da82e9108 100644 --- a/apps/backend/internal/github/branch_protection.go +++ b/apps/backend/internal/github/branch_protection.go @@ -69,6 +69,12 @@ func (c *branchProtectionCache) set(key string, bp BranchProtection) { c.m[key] = bp } +func (c *branchProtectionCache) clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.m = make(map[string]BranchProtection) +} + func branchProtectionKey(owner, repo, branch string) string { return owner + "/" + repo + "@" + branch } @@ -89,10 +95,26 @@ type BranchProtectionFetcher interface { // Result is cached for branchProtectionCacheTTL. nil never gets cached so a // transient error is retried on the next sync. func (s *Service) fetchRequiredReviews(ctx context.Context, owner, repo, branch string) *int { + return s.fetchRequiredReviewsWithClient(ctx, s.client, "legacy", owner, repo, branch) +} + +func (s *Service) fetchRequiredReviewsForWorkspace( + ctx context.Context, workspaceID, owner, repo, branch string, +) *int { + resolved, err := s.resolveAutomationClient(ctx, workspaceID, owner, repo) + if err != nil { + return nil + } + return s.fetchRequiredReviewsWithClient(ctx, resolved.Client, resolved.CacheScope, owner, repo, branch) +} + +func (s *Service) fetchRequiredReviewsWithClient( + ctx context.Context, client Client, cacheScope, owner, repo, branch string, +) *int { if owner == "" || repo == "" || branch == "" { return nil } - key := branchProtectionKey(owner, repo, branch) + key := scopedCacheKey(cacheScope, branchProtectionKey(owner, repo, branch)) if bp, ok := s.protectionCache.get(key); ok { if !bp.HasRule { return nil @@ -100,7 +122,7 @@ func (s *Service) fetchRequiredReviews(ctx context.Context, owner, repo, branch n := bp.RequiredApprovingReviewCount return &n } - fetcher, ok := s.client.(BranchProtectionFetcher) + fetcher, ok := client.(BranchProtectionFetcher) if !ok { return nil } diff --git a/apps/backend/internal/github/controller.go b/apps/backend/internal/github/controller.go index 52c9412178..fba50387b0 100644 --- a/apps/backend/internal/github/controller.go +++ b/apps/backend/internal/github/controller.go @@ -16,6 +16,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/repository/repoerrors" ) const ( @@ -45,6 +46,28 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.GET("/status", c.httpGetStatus) api.POST("/token", c.httpConfigureToken) api.DELETE("/token", c.httpClearToken) + api.GET("/connections", c.httpGetWorkspaceConnection) + api.GET("/auth/gh-cli/accounts", c.httpListCLIAccounts) + api.GET("/cli/accounts", c.httpListCLIAccounts) + api.PUT("/workspace-connection", c.httpSetWorkspaceConnection) + api.DELETE("/workspace-connection", c.httpDeleteWorkspaceConnection) + api.PUT("/connections/:workspaceId", c.httpSetWorkspaceConnection) + api.DELETE("/connections/:workspaceId", c.httpDeleteWorkspaceConnection) + api.GET("/app/registrations", c.httpListAppRegistrations) + api.POST("/app/registrations/manifest/start", c.httpStartAppRegistrationManifest) + api.GET("/app/registrations/:registrationId/manifest/callback", c.httpCompleteAppRegistrationManifest) + api.POST("/app/registrations/import/prepare", c.httpPrepareAppRegistrationImport) + api.POST("/app/registrations/import", c.httpImportAppRegistration) + api.PATCH("/app/registrations/:registrationId", c.httpRenameAppRegistration) + api.DELETE("/app/registrations/:registrationId", c.httpDeleteAppRegistration) + api.POST("/app/install/start", c.httpStartAppInstallation) + api.GET("/app/registrations/:registrationId/install/callback", c.httpCompleteAppInstallation) + api.POST("/app/registrations/:registrationId/webhook", c.httpGitHubAppWebhook) + api.POST("/personal-connection/start", c.httpStartPersonalAuth) + api.GET("/app/registrations/:registrationId/personal/callback", c.httpCompletePersonalAuth) + api.DELETE("/personal-connection", c.httpDisconnectPersonalAuth) + api.GET("/credentials/resolve", c.httpCredentialBrokerReady) + api.POST("/credentials/resolve", c.httpResolveCredentialLease) api.GET("/task-prs", c.httpListTaskPRs) api.POST("/task-prs", c.httpCreateTaskPR) @@ -108,22 +131,33 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { } func (c *Controller) httpGetStatus(ctx *gin.Context) { - status, err := c.service.GetStatus(ctx.Request.Context()) + workspaceID := ctx.Query("workspace_id") + status, err := c.service.GetWorkspaceAuthStatus( + ctx.Request.Context(), workspaceID, currentGitHubUserID(ctx), + ) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeGitHubAuthError(ctx, err) return } ctx.JSON(http.StatusOK, status) } func (c *Controller) httpConfigureToken(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + writeGitHubAuthError(ctx, ErrGitHubWorkspaceRequired) + return + } var req ConfigureTokenRequest if err := ctx.ShouldBindJSON(&req); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: token is required"}) return } - if err := c.service.ConfigureToken(ctx.Request.Context(), req.Token); err != nil { + if _, err := c.service.SetWorkspaceConnection(ctx.Request.Context(), workspaceID, SetWorkspaceConnectionRequest{ + Source: ConnectionSourcePAT, + Token: req.Token, + }); err != nil { if errors.Is(err, ErrInvalidToken) { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -132,15 +166,24 @@ func (c *Controller) httpConfigureToken(ctx *gin.Context) { return } - ctx.JSON(http.StatusOK, gin.H{"configured": true}) + ctx.Header("Deprecation", "true") + ctx.Header("Sunset", "one-release") + ctx.JSON(http.StatusOK, gin.H{"configured": true, "workspace_id": workspaceID}) } func (c *Controller) httpClearToken(ctx *gin.Context) { - if err := c.service.ClearToken(ctx.Request.Context()); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + writeGitHubAuthError(ctx, ErrGitHubWorkspaceRequired) return } - ctx.JSON(http.StatusOK, gin.H{"cleared": true}) + if err := c.service.DeleteWorkspaceConnection(ctx.Request.Context(), workspaceID); err != nil { + writeGitHubAuthError(ctx, err) + return + } + ctx.Header("Deprecation", "true") + ctx.Header("Sunset", "one-release") + ctx.JSON(http.StatusOK, gin.H{"cleared": true, "workspace_id": workspaceID}) } func (c *Controller) httpListTaskPRs(ctx *gin.Context) { @@ -178,6 +221,7 @@ func (c *Controller) httpListTaskPRs(ctx *gin.Context) { // branches that don't exist on GitHub). func (c *Controller) httpCreateTaskPR(ctx *gin.Context) { var req struct { + WorkspaceID string `json:"workspace_id"` TaskID string `json:"task_id"` RepositoryID string `json:"repository_id"` PRURL string `json:"pr_url"` @@ -186,11 +230,14 @@ func (c *Controller) httpCreateTaskPR(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - if req.TaskID == "" || req.PRURL == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "task_id and pr_url are required"}) + if req.WorkspaceID == "" || req.TaskID == "" || req.PRURL == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id, task_id and pr_url are required"}) return } - tp, err := c.service.AssociateExistingPRByURL(ctx.Request.Context(), req.TaskID, req.RepositoryID, req.PRURL) + tp, err := c.service.AssociateExistingPRByURLForWorkspace( + ctx.Request.Context(), req.WorkspaceID, currentGitHubUserID(ctx), + req.TaskID, req.RepositoryID, req.PRURL, + ) if err != nil { status := http.StatusInternalServerError if errors.Is(err, ErrInvalidPRURL) { @@ -236,7 +283,9 @@ func (c *Controller) httpLinkTaskIssue(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - resp, err := c.service.LinkTaskIssue(ctx.Request.Context(), ctx.Param("taskId"), req) + resp, err := c.service.LinkTaskIssueForUser( + ctx.Request.Context(), currentGitHubUserID(ctx), ctx.Param("taskId"), req, + ) if err != nil { c.handleTaskIssueLinkError(ctx, err) return @@ -254,7 +303,7 @@ func (c *Controller) httpUnlinkTaskIssue(ctx *gin.Context) { func (c *Controller) handleTaskIssueLinkError(ctx *gin.Context, err error) { switch { - case errors.Is(err, ErrNoClient): + case isGitHubNotConfiguredError(err): ctx.JSON(http.StatusServiceUnavailable, gin.H{ "error": "GitHub is not configured. Connect GitHub in Settings > Integrations.", "code": "github_not_configured", @@ -383,7 +432,9 @@ func (c *Controller) httpGetPRFeedback(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid PR number"}) return } - feedback, err := c.service.GetPRFeedback(ctx.Request.Context(), owner, repo, number) + feedback, err := c.service.GetPRFeedbackForWorkspace( + ctx.Request.Context(), ctx.Query("workspace_id"), currentGitHubUserID(ctx), owner, repo, number, + ) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -400,7 +451,9 @@ func (c *Controller) httpGetPRStatus(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid PR number"}) return } - status, err := c.service.GetPRStatus(ctx.Request.Context(), owner, repo, number) + status, err := c.service.GetPRStatusForWorkspace( + ctx.Request.Context(), ctx.Query("workspace_id"), currentGitHubUserID(ctx), owner, repo, number, + ) if err != nil { c.handleSearchError(ctx, err) return @@ -419,7 +472,8 @@ const prStatusesBatchMaxRefs = 200 // fail upstream are omitted rather than failing the whole batch. func (c *Controller) httpGetPRStatusesBatch(ctx *gin.Context) { var body struct { - Refs []PRRef `json:"refs"` + WorkspaceID string `json:"workspace_id"` + Refs []PRRef `json:"refs"` } if err := ctx.ShouldBindJSON(&body); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) @@ -429,7 +483,9 @@ func (c *Controller) httpGetPRStatusesBatch(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "too many refs"}) return } - statuses, err := c.service.GetPRStatusesBatch(ctx.Request.Context(), body.Refs) + statuses, err := c.service.GetPRStatusesBatchForWorkspace( + ctx.Request.Context(), body.WorkspaceID, currentGitHubUserID(ctx), body.Refs, + ) if err != nil { c.handleSearchError(ctx, err) return @@ -444,7 +500,9 @@ func (c *Controller) httpGetPRInfo(ctx *gin.Context) { if !ok { return } - pr, err := c.service.GetPR(ctx.Request.Context(), owner, repo, number) + pr, err := c.service.GetPRForWorkspace( + ctx.Request.Context(), ctx.Query("workspace_id"), currentGitHubUserID(ctx), owner, repo, number, + ) if err != nil { handleGitHubInfoError(ctx, err) return @@ -459,7 +517,9 @@ func (c *Controller) httpGetIssueInfo(ctx *gin.Context) { if !ok { return } - issue, err := c.service.GetIssue(ctx.Request.Context(), owner, repo, number) + issue, err := c.service.GetIssueForWorkspace( + ctx.Request.Context(), ctx.Query("workspace_id"), currentGitHubUserID(ctx), owner, repo, number, + ) if err != nil { handleGitHubInfoError(ctx, err) return @@ -477,7 +537,7 @@ func parseRouteNumber(ctx *gin.Context, invalidMessage string) (int, bool) { } func handleGitHubInfoError(ctx *gin.Context, err error) { - if errors.Is(err, ErrNoClient) { + if isGitHubNotConfiguredError(err) { ctx.JSON(http.StatusServiceUnavailable, gin.H{ "error": "GitHub is not configured. Connect GitHub in Settings > Integrations.", "code": "github_not_configured", @@ -525,15 +585,22 @@ func (c *Controller) httpSubmitReview(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "event must be APPROVE, COMMENT, or REQUEST_CHANGES"}) return } - if err := c.service.SubmitReview(ctx.Request.Context(), owner, repo, number, req.Event, req.Body); err != nil { + principal, err := c.service.SubmitReviewForWorkspace( + ctx.Request.Context(), ctx.Query("workspace_id"), currentGitHubUserID(ctx), + owner, repo, number, req.Event, req.Body, + ) + if err != nil { if errors.Is(err, ErrSelfApprove) { ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } + if writeGitHubOperationalAuthError(ctx, err) { + return + } ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - ctx.JSON(http.StatusOK, gin.H{"submitted": true}) + ctx.JSON(http.StatusOK, gin.H{"submitted": true, "principal": principal}) } func (c *Controller) httpRequestReviewers(ctx *gin.Context) { @@ -555,15 +622,18 @@ func (c *Controller) httpRequestReviewers(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "reviewers must contain non-empty logins"}) return } - if err := c.service.RequestReviewers(ctx.Request.Context(), ctx.Param("owner"), ctx.Param("repo"), number, reviewers); err != nil { - if errors.Is(err, ErrNoClient) { - ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "GitHub is not configured", "code": "github_not_configured"}) + principal, err := c.service.RequestReviewersForWorkspace( + ctx.Request.Context(), ctx.Query("workspace_id"), currentGitHubUserID(ctx), + ctx.Param("owner"), ctx.Param("repo"), number, reviewers, + ) + if err != nil { + if writeGitHubOperationalAuthError(ctx, err) { return } ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - ctx.JSON(http.StatusOK, gin.H{"requested": true}) + ctx.JSON(http.StatusOK, gin.H{"requested": true, "principal": principal}) } func normalizeReviewers(reviewers []string) ([]string, bool) { @@ -611,12 +681,12 @@ func (c *Controller) httpMergePR(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "merge_method must be merge, squash, or rebase"}) return } - if err := c.service.MergePR(ctx.Request.Context(), owner, repo, number, req.MergeMethod); err != nil { - if errors.Is(err, ErrNoClient) { - ctx.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "GitHub is not configured. Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.", - "code": "github_not_configured", - }) + principal, err := c.service.MergePRForWorkspace( + ctx.Request.Context(), ctx.Query("workspace_id"), currentGitHubUserID(ctx), + owner, repo, number, req.MergeMethod, + ) + if err != nil { + if writeGitHubOperationalAuthError(ctx, err) { return } status := http.StatusInternalServerError @@ -636,12 +706,20 @@ func (c *Controller) httpMergePR(ctx *gin.Context) { ctx.JSON(status, gin.H{"error": err.Error()}) return } - ctx.JSON(http.StatusOK, gin.H{"merged": true}) + ctx.JSON(http.StatusOK, gin.H{"merged": true, "principal": principal}) } func (c *Controller) httpListPRWatches(ctx *gin.Context) { - watches, err := c.service.ListActivePRWatches(ctx.Request.Context()) + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) + return + } + watches, err := c.service.ListActivePRWatchesForWorkspace(ctx.Request.Context(), workspaceID) if err != nil { + if writeWatchWorkspaceError(ctx, "PR watch", err) { + return + } ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -650,27 +728,30 @@ func (c *Controller) httpListPRWatches(ctx *gin.Context) { func (c *Controller) httpDeletePRWatch(ctx *gin.Context) { id := ctx.Param("id") + if !c.requirePRWatchInWorkspace(ctx, id) { + return + } if err := c.service.DeletePRWatch(ctx.Request.Context(), id); err != nil { + if writeWatchWorkspaceError(ctx, "PR watch", err) { + return + } ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } ctx.JSON(http.StatusOK, gin.H{"deleted": true}) } -// httpListReviewWatches returns watches scoped to one workspace when -// `workspace_id` is supplied, or every watch across all workspaces when it -// is absent. The integration settings page uses the unscoped form. +// httpListReviewWatches returns watches scoped to the requested workspace. +// The all-workspaces service method is reserved for identity-less internal +// pollers; exposing it through HTTP would disclose watch prompts and routing +// configuration across workspaces. func (c *Controller) httpListReviewWatches(ctx *gin.Context) { workspaceID := ctx.Query("workspace_id") - var ( - watches []*ReviewWatch - err error - ) if workspaceID == "" { - watches, err = c.service.ListAllReviewWatches(ctx.Request.Context()) - } else { - watches, err = c.service.ListReviewWatches(ctx.Request.Context(), workspaceID) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) + return } + watches, err := c.service.ListReviewWatches(ctx.Request.Context(), workspaceID) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -684,7 +765,7 @@ func (c *Controller) httpCreateReviewWatch(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - rw, err := c.service.CreateReviewWatch(ctx.Request.Context(), &req) + rw, err := c.service.CreateReviewWatchForUser(ctx.Request.Context(), currentGitHubUserID(ctx), &req) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -765,7 +846,9 @@ func (c *Controller) httpTriggerAllReviewChecks(ctx *gin.Context) { } func (c *Controller) httpListUserOrgs(ctx *gin.Context) { - orgs, err := c.service.ListUserOrgs(ctx.Request.Context()) + orgs, err := c.service.ListUserOrgsForWorkspace( + ctx.Request.Context(), ctx.Query("workspace_id"), currentGitHubUserID(ctx), + ) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -784,9 +867,11 @@ func (c *Controller) httpListUserOrgs(ctx *gin.Context) { func (c *Controller) httpListAccessibleRepos(ctx *gin.Context) { query := ctx.Query("q") limit, _ := strconv.Atoi(ctx.Query("limit")) - repos, err := c.service.ListAccessibleRepos(ctx.Request.Context(), query, limit) + repos, err := c.service.ListAccessibleReposForWorkspace( + ctx.Request.Context(), ctx.Query("workspace_id"), currentGitHubUserID(ctx), query, limit, + ) if err != nil { - if errors.Is(err, ErrNoClient) { + if isGitHubNotConfiguredError(err) { ctx.JSON(http.StatusServiceUnavailable, gin.H{ "error": "GitHub is not configured. Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.", "code": "github_not_configured", @@ -818,7 +903,9 @@ func (c *Controller) httpSearchRepos(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "org query parameter required"}) return } - repos, err := c.service.SearchOrgRepos(ctx.Request.Context(), org, query, 20) + repos, err := c.service.SearchOrgReposForWorkspace( + ctx.Request.Context(), ctx.Query("workspace_id"), org, query, 20, + ) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -829,9 +916,11 @@ func (c *Controller) httpSearchRepos(ctx *gin.Context) { func (c *Controller) httpListRepoBranches(ctx *gin.Context) { owner := ctx.Param("owner") repo := ctx.Param("repo") - branches, err := c.service.ListRepoBranches(ctx.Request.Context(), owner, repo) + branches, err := c.service.ListRepoBranchesForWorkspace( + ctx.Request.Context(), ctx.Query("workspace_id"), owner, repo, + ) if err != nil { - if errors.Is(err, ErrNoClient) { + if isGitHubNotConfiguredError(err) { ctx.JSON(http.StatusServiceUnavailable, gin.H{ "error": "GitHub is not configured. Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.", "code": "github_not_configured", @@ -859,9 +948,11 @@ func (c *Controller) httpListRepoBranches(ctx *gin.Context) { func (c *Controller) httpGetRepoMergeMethods(ctx *gin.Context) { owner := ctx.Param("owner") repo := ctx.Param("repo") - methods, err := c.service.GetRepoMergeMethods(ctx.Request.Context(), owner, repo) + methods, err := c.service.GetRepoMergeMethodsForWorkspace( + ctx.Request.Context(), ctx.Query("workspace_id"), currentGitHubUserID(ctx), owner, repo, + ) if err != nil { - if errors.Is(err, ErrNoClient) { + if isGitHubNotConfiguredError(err) { ctx.JSON(http.StatusServiceUnavailable, gin.H{ "error": "GitHub is not configured. Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.", "code": "github_not_configured", @@ -902,11 +993,9 @@ func (c *Controller) httpSearchUserPRs(ctx *gin.Context) { result *PRSearchPage err error ) - if workspaceID == "" { - result, err = c.service.SearchUserPRsPaged(ctx.Request.Context(), filter, query, page, perPage) - } else { - result, err = c.service.SearchUserPRsPagedForWorkspace(ctx.Request.Context(), workspaceID, filter, query, page, perPage) - } + result, err = c.service.SearchUserPRsPagedForWorkspaceUser( + ctx.Request.Context(), workspaceID, currentGitHubUserID(ctx), filter, query, page, perPage, + ) if err != nil { c.handleSearchError(ctx, err) return @@ -927,11 +1016,9 @@ func (c *Controller) httpSearchUserIssues(ctx *gin.Context) { result *IssueSearchPage err error ) - if workspaceID == "" { - result, err = c.service.SearchUserIssuesPaged(ctx.Request.Context(), filter, query, page, perPage) - } else { - result, err = c.service.SearchUserIssuesPagedForWorkspace(ctx.Request.Context(), workspaceID, filter, query, page, perPage) - } + result, err = c.service.SearchUserIssuesPagedForWorkspaceUser( + ctx.Request.Context(), workspaceID, currentGitHubUserID(ctx), filter, query, page, perPage, + ) if err != nil { c.handleSearchError(ctx, err) return @@ -1027,7 +1114,7 @@ func parsePaginationQuery(ctx *gin.Context) (int, int) { // handleSearchError maps client errors to proper HTTP responses. func (c *Controller) handleSearchError(ctx *gin.Context, err error) { - if errors.Is(err, ErrNoClient) { + if isGitHubNotConfiguredError(err) { ctx.JSON(http.StatusServiceUnavailable, gin.H{ "error": "GitHub is not configured. Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.", "code": "github_not_configured", @@ -1082,16 +1169,15 @@ func (c *Controller) httpGetStats(ctx *gin.Context) { func (c *Controller) httpListIssueWatches(ctx *gin.Context) { workspaceID := ctx.Query("workspace_id") - var ( - watches []*IssueWatch - err error - ) if workspaceID == "" { - watches, err = c.service.ListAllIssueWatches(ctx.Request.Context()) - } else { - watches, err = c.service.ListIssueWatches(ctx.Request.Context(), workspaceID) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) + return } + watches, err := c.service.ListIssueWatches(ctx.Request.Context(), workspaceID) if err != nil { + if writeWatchWorkspaceError(ctx, "issue watch", err) { + return + } ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -1104,8 +1190,11 @@ func (c *Controller) httpCreateIssueWatch(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - iw, err := c.service.CreateIssueWatch(ctx.Request.Context(), &req) + iw, err := c.service.CreateIssueWatchForWorkspace(ctx.Request.Context(), &req) if err != nil { + if writeWatchWorkspaceError(ctx, "issue watch", err) { + return + } ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -1148,17 +1237,18 @@ func (c *Controller) httpDeleteIssueWatch(ctx *gin.Context) { func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) { id := ctx.Param("id") - workspaceID := ctx.Query("workspace_id") - if workspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) + if !c.requireIssueWatchInWorkspace(ctx, id) { return } watch, err := c.service.GetIssueWatch(ctx.Request.Context(), id) if err != nil { + if writeWatchWorkspaceError(ctx, "issue watch", err) { + return + } ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if watch == nil || watch.WorkspaceID != workspaceID { + if watch == nil { ctx.JSON(http.StatusNotFound, gin.H{"error": "issue watch not found"}) return } @@ -1178,6 +1268,30 @@ func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"new_issues_found": len(newIssues), "issues": newIssues, "cleaned": cleaned}) } +func (c *Controller) requirePRWatchInWorkspace(ctx *gin.Context, id string) bool { + watch, err := c.service.GetPRWatch(ctx.Request.Context(), id) + if err != nil { + if writeWatchWorkspaceError(ctx, "PR watch", err) { + return false + } + c.writeResetError(ctx, "load PR watch", err) + return false + } + if watch == nil || ctx.Query("workspace_id") == "" || ctx.Query("workspace_id") != watch.WorkspaceID { + ctx.JSON(http.StatusNotFound, gin.H{"error": "PR watch not found"}) + return false + } + return true +} + +func writeWatchWorkspaceError(ctx *gin.Context, resource string, err error) bool { + if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + ctx.JSON(http.StatusNotFound, gin.H{"error": strings.ToLower(resource) + " not found"}) + return true + } + return false +} + func (c *Controller) httpTriggerAllIssueChecks(ctx *gin.Context) { workspaceID := ctx.Query("workspace_id") if workspaceID == "" { diff --git a/apps/backend/internal/github/controller_app_registration.go b/apps/backend/internal/github/controller_app_registration.go new file mode 100644 index 0000000000..43c33c0b70 --- /dev/null +++ b/apps/backend/internal/github/controller_app_registration.go @@ -0,0 +1,220 @@ +package github + +import ( + "crypto/sha256" + "errors" + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" +) + +const ( + maxDeploymentAppManifestConversionCodeLength = 1024 + maxDeploymentAppProviderErrorLength = 256 +) + +func (c *Controller) httpListAppRegistrations(ctx *gin.Context) { + result, err := c.service.ListAppRegistrationCatalog( + ctx.Request.Context(), currentGitHubUserID(ctx), ctx.Query("workspace_id"), + ) + if err != nil { + writeGitHubAuthError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpStartAppRegistrationManifest(ctx *gin.Context) { + var request AppRegistrationManifestStartRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + writeAppRegistrationInvalidRequest(ctx) + return + } + result, err := c.service.StartAppRegistrationManifest( + ctx.Request.Context(), currentGitHubUserID(ctx), request, + ) + if err != nil { + writeGitHubAuthError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpCompleteAppRegistrationManifest(ctx *gin.Context) { + state, code, providerError := ctx.Query("state"), ctx.Query("code"), ctx.Query("error") + if !validGitHubCallbackState(state) || + len(code) > maxDeploymentAppManifestConversionCodeLength || + len(providerError) > maxDeploymentAppProviderErrorLength { + redirectAppRegistrationCallback(ctx, "", "github_app_invalid_callback") + return + } + result, err := c.service.CompleteAppRegistrationManifest( + ctx.Request.Context(), ctx.Param("registrationId"), AppRegistrationManifestCallback{ + State: state, Code: code, Error: providerError, + }, + ) + if err != nil { + workspaceID := appRegistrationFlowWorkspace(ctx, c.service, state) + redirectAppRegistrationCallback(ctx, workspaceID, githubAuthErrorCode(err)) + return + } + redirectAppRegistrationCallback(ctx, result.WorkspaceID, "app_registered") +} + +func appRegistrationFlowWorkspace(ctx *gin.Context, service *Service, state string) string { + if service == nil || service.store == nil || state == "" { + return "" + } + digest := stateDigest(state) + flow, err := service.store.GetDeploymentAppRegistrationFlow(ctx.Request.Context(), digest) + if err != nil || flow == nil { + return "" + } + return flow.WorkspaceID +} + +func stateDigest(state string) string { + digest := sha256Digest(strings.TrimSpace(state)) + return stateDigestString(digest) +} + +func sha256Digest(value string) [32]byte { + return sha256.Sum256([]byte(value)) +} + +func (c *Controller) httpImportAppRegistration(ctx *gin.Context) { + var request AppRegistrationImportRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + writeAppRegistrationInvalidRequest(ctx) + return + } + registration, err := c.service.ImportAppRegistration( + ctx.Request.Context(), currentGitHubUserID(ctx), request, + ) + if err != nil { + writeGitHubAuthError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusCreated, registration) +} + +func (c *Controller) httpPrepareAppRegistrationImport(ctx *gin.Context) { + var request AppRegistrationImportPrepareRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + writeAppRegistrationInvalidRequest(ctx) + return + } + preparation, err := c.service.PrepareAppRegistrationImport( + ctx.Request.Context(), currentGitHubUserID(ctx), request, + ) + if err != nil { + writeGitHubAuthError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusCreated, preparation) +} + +func (c *Controller) httpRenameAppRegistration(ctx *gin.Context) { + var request struct { + DisplayName string `json:"display_name"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + writeAppRegistrationInvalidRequest(ctx) + return + } + registration, err := c.service.RenameAppRegistration( + ctx.Request.Context(), currentGitHubUserID(ctx), ctx.Param("registrationId"), request.DisplayName, + ) + if err != nil { + writeGitHubAuthError(ctx, err) + return + } + ctx.JSON(http.StatusOK, registration) +} + +func (c *Controller) httpDeleteAppRegistration(ctx *gin.Context) { + err := c.service.DeleteAppRegistration( + ctx.Request.Context(), currentGitHubUserID(ctx), ctx.Param("registrationId"), + ) + if err != nil { + var inUse *AppRegistrationInUseError + if errors.As(err, &inUse) { + ctx.JSON(http.StatusConflict, gin.H{ + "code": "github_app_registration_in_use", "error": inUse.Error(), + "binding_count": inUse.BindingCount, + }) + return + } + writeGitHubAuthError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func writeAppRegistrationInvalidRequest(ctx *gin.Context) { + ctx.JSON(http.StatusBadRequest, gin.H{ + "code": "github_app_invalid_request", "error": "invalid GitHub App registration request", + }) +} + +func redirectAppRegistrationCallback(ctx *gin.Context, workspaceID, result string) { + query := url.Values{"github_result": []string{result}} + if workspaceID != "" { + query.Set("workspace_id", workspaceID) + } + ctx.Header("Cache-Control", "no-store") + ctx.Header("Referrer-Policy", "no-referrer") + ctx.Redirect(http.StatusSeeOther, "/settings/integrations/github?"+query.Encode()) +} + +func deploymentAppHTTPError(err error) (int, string, bool) { + switch { + case errors.Is(err, ErrDeploymentAppOperatorRequired): + return http.StatusForbidden, "github_app_operator_required", true + case errors.Is(err, ErrDeploymentAppRegistrationCancelled): + return http.StatusBadRequest, "github_app_registration_cancelled", true + case errors.Is(err, ErrAppRegistrationNotFound): + return http.StatusNotFound, "github_app_registration_not_found", true + case errors.Is(err, ErrDeploymentAppInUse): + return http.StatusConflict, "github_app_registration_in_use", true + case errors.Is(err, ErrDeploymentAppManifestOwnerInvalid), + errors.Is(err, ErrAppRegistrationIDInvalid), + errors.Is(err, ErrAppRegistrationVisibilityInvalid): + return http.StatusBadRequest, "github_app_invalid_request", true + case errors.Is(err, ErrPublicGitHubBaseURLInvalid), + errors.Is(err, ErrPublicGitHubBaseURLNotGlobal), + errors.Is(err, ErrPublicGitHubBaseURLUnresolvable): + return http.StatusBadRequest, "github_app_public_url_invalid", true + case errors.Is(err, ErrDeploymentAppManifestStateUnavailable), + errors.Is(err, ErrDeploymentAppIdentityMismatch), + errors.Is(err, ErrDeploymentAppPolicyMismatch): + return http.StatusBadRequest, "github_app_invalid_callback", true + case errors.Is(err, ErrAppRegistrationImportPreparationUnavailable): + return http.StatusBadRequest, "github_app_import_preparation_invalid", true + } + var importError *AppRegistrationImportError + if errors.As(err, &importError) { + code := "github_app_" + string(importError.Code) + if importError.Code == AppRegistrationImportAlreadyRegistered { + return http.StatusConflict, "github_app_already_registered", true + } + if importError.Code == AppRegistrationImportInvalidRequest { + return http.StatusBadRequest, "github_app_invalid_request", true + } + if importError.Code == AppRegistrationImportPersistenceFailed { + return http.StatusInternalServerError, code, true + } + return http.StatusUnprocessableEntity, code, true + } + var conversionError *ManifestConversionError + if errors.As(err, &conversionError) { + return http.StatusBadGateway, string(conversionError.Code), true + } + return 0, "", false +} diff --git a/apps/backend/internal/github/controller_app_registration_test.go b/apps/backend/internal/github/controller_app_registration_test.go new file mode 100644 index 0000000000..88f0d18d86 --- /dev/null +++ b/apps/backend/internal/github/controller_app_registration_test.go @@ -0,0 +1,694 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/kandev/kandev/internal/auth/authn" +) + +type blockingAppRegistrationRuntime struct { + applyStarted chan struct{} + releaseApply chan struct{} + mu sync.Mutex + active bool +} + +type failingAppRegistrationRuntime struct { + mu sync.Mutex + active bool + applyErr error + invalidated bool +} + +func (r *failingAppRegistrationRuntime) ValidateAppRegistrationRuntime(ResolvedDeploymentAppConfig) error { + return nil +} + +func (r *failingAppRegistrationRuntime) ApplyAppRegistrationRuntime(ResolvedDeploymentAppConfig) error { + return r.applyErr +} + +func (r *failingAppRegistrationRuntime) InvalidateAppRegistrationRuntime(string, int64) { + r.mu.Lock() + r.active = false + r.invalidated = true + r.mu.Unlock() +} + +func (r *failingAppRegistrationRuntime) state() (bool, bool) { + r.mu.Lock() + defer r.mu.Unlock() + return r.active, r.invalidated +} + +func (r *blockingAppRegistrationRuntime) ValidateAppRegistrationRuntime(ResolvedDeploymentAppConfig) error { + return nil +} + +func (r *blockingAppRegistrationRuntime) ApplyAppRegistrationRuntime(ResolvedDeploymentAppConfig) error { + close(r.applyStarted) + <-r.releaseApply + r.mu.Lock() + r.active = true + r.mu.Unlock() + return nil +} + +func (r *blockingAppRegistrationRuntime) InvalidateAppRegistrationRuntime(string, int64) { + r.mu.Lock() + r.active = false + r.mu.Unlock() +} + +func (r *blockingAppRegistrationRuntime) isActive() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.active +} + +func setupAppRegistrationController(t *testing.T) (*gin.Engine, *Service, *Store) { + t.Helper() + gin.SetMode(gin.TestMode) + store := newTestStore(t) + secrets := newFakeConnectionSecrets() + service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + service.SetConnectionSecretStore(secrets) + repository := NewAppRegistrationRepository(store, secrets) + conversion := testManifestConversion(t, "acme", "Organization") + service.appRegistrationLifecycle = NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ + Repository: repository, Store: store, Runtime: service, + Converter: manifestConverterFunc(func(context.Context, string) (ManifestConversionResult, error) { + return conversion, nil + }), + Resolver: manifestResolverFunc(func(context.Context, string, string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("203.0.114.10")}, nil + }), + Importer: NewAppRegistrationImporter(repository, manifestResolverFunc(func( + context.Context, string, string, + ) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("203.0.114.10")}, nil + }), func(int64, []byte) (AppRegistrationVerifier, error) { + return appRegistrationVerifierFunc(func(context.Context) (AuthenticatedApp, error) { + return AuthenticatedApp{ + ID: 101, ClientID: "Iv1.client", Slug: "kandev-app", + OwnerLogin: "acme", OwnerType: "Organization", + ExternalURL: "https://kandev.example", + Permissions: conversion.Permissions, Events: conversion.Events, + }, nil + }), nil + }), + Random: strings.NewReader(strings.Repeat("h", oauthRandomBytes*3)), + }) + router := gin.New() + NewController(service, testLogger(t)).RegisterHTTPRoutes(router) + return router, service, store +} + +func TestHTTPAppRegistrationManifestIsRouteBoundAndReturnsToWorkspace(t *testing.T) { + router, _, store := setupAppRegistrationController(t) + seedConnectionWorkspaces(t, store, "workspace-1") + start := serveRegistrationJSON(t, router, http.MethodPost, + "/api/v1/github/app/registrations/manifest/start", `{ + "workspace_id":"workspace-1","display_name":"Work automation", + "owner_type":"organization","owner_login":"acme","visibility":"private", + "public_base_url":"https://kandev.example" + }`) + if start.Code != http.StatusOK { + t.Fatalf("start = %d %s", start.Code, start.Body.String()) + } + var started AppRegistrationManifestStart + if err := json.Unmarshal(start.Body.Bytes(), &started); err != nil || started.RegistrationID == "" { + t.Fatalf("start response = %s, error = %v", start.Body.String(), err) + } + + wrong := httptest.NewRecorder() + router.ServeHTTP(wrong, httptest.NewRequest(http.MethodGet, + "/api/v1/github/app/registrations/"+uuid.NewString()+ + "/manifest/callback?state="+started.State+"&code=one-time-code", nil)) + assertPrivateGitHubRedirect(t, wrong) + flowHash := stateDigest(started.State) + flow, err := store.GetDeploymentAppRegistrationFlow(context.Background(), flowHash) + if err != nil || flow == nil || flow.ConsumedAt != nil { + t.Fatalf("wrong route consumed flow: flow=%+v err=%v", flow, err) + } + + callback := httptest.NewRecorder() + router.ServeHTTP(callback, httptest.NewRequest(http.MethodGet, + "/api/v1/github/app/registrations/"+started.RegistrationID+ + "/manifest/callback?state="+started.State+"&code=one-time-code", nil)) + assertPrivateGitHubRedirect(t, callback) + location := callback.Header().Get("Location") + if !strings.Contains(location, "workspace_id=workspace-1") || + !strings.Contains(location, "github_result=app_registered") { + t.Fatalf("callback location = %q", location) + } + + catalog := httptest.NewRecorder() + router.ServeHTTP(catalog, httptest.NewRequest(http.MethodGet, + "/api/v1/github/app/registrations?workspace_id=workspace-1", nil)) + if catalog.Code != http.StatusOK || !strings.Contains(catalog.Body.String(), `"display_name":"Work automation"`) { + t.Fatalf("catalog = %d %s", catalog.Code, catalog.Body.String()) + } + for _, secret := range []string{"generated-client", "generated-webhook", "PRIVATE KEY"} { + if strings.Contains(catalog.Body.String()+location, secret) { + t.Fatalf("registration response leaked %q", secret) + } + } +} + +func TestHTTPAppRegistrationCatalogRequiresAdminIdentity(t *testing.T) { + router, _, store := setupAppRegistrationController(t) + seedConnectionWorkspaces(t, store, "workspace-1") + + memberReq := httptest.NewRequest(http.MethodGet, + "/api/v1/github/app/registrations?workspace_id=workspace-1", nil) + memberReq = memberReq.WithContext(authn.WithIdentity(memberReq.Context(), authn.Identity{ + UserID: "member-1", Role: authn.RoleMember, + })) + member := httptest.NewRecorder() + router.ServeHTTP(member, memberReq) + if member.Code != http.StatusForbidden { + t.Fatalf("member status = %d, want %d: %s", member.Code, http.StatusForbidden, member.Body.String()) + } + + adminReq := httptest.NewRequest(http.MethodGet, + "/api/v1/github/app/registrations?workspace_id=workspace-1", nil) + adminReq = adminReq.WithContext(authn.WithIdentity(adminReq.Context(), authn.Identity{ + UserID: "admin-1", Role: authn.RoleAdmin, + })) + admin := httptest.NewRecorder() + router.ServeHTTP(admin, adminReq) + if admin.Code != http.StatusOK { + t.Fatalf("admin status = %d, want %d: %s", admin.Code, http.StatusOK, admin.Body.String()) + } +} + +func TestAppRegistrationDeleteWaitsForRuntimePublication(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + runtime := &blockingAppRegistrationRuntime{ + applyStarted: make(chan struct{}), releaseApply: make(chan struct{}), + } + conversion := testManifestConversion(t, "acme", "Organization") + lifecycle := NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ + Repository: repository, Store: store, Runtime: runtime, + Converter: manifestConverterFunc(func(context.Context, string) (ManifestConversionResult, error) { + return conversion, nil + }), + Resolver: manifestResolverFunc(func(context.Context, string, string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("203.0.114.10")}, nil + }), + Random: strings.NewReader(strings.Repeat("r", oauthRandomBytes*3)), + }) + ctx := context.Background() + started, err := lifecycle.StartManifest(ctx, DefaultUserID, AppRegistrationManifestStartRequest{ + WorkspaceID: "workspace-1", DisplayName: "Concurrent App", + OwnerType: ManifestOwnerOrganization, OwnerLogin: "acme", + Visibility: AppRegistrationVisibilityPrivate, PublicBaseURL: "https://kandev.example", + }) + if err != nil { + t.Fatal(err) + } + completeResult := make(chan error, 1) + go func() { + _, completeErr := lifecycle.CompleteManifest(ctx, started.RegistrationID, AppRegistrationManifestCallback{ + State: started.State, Code: "one-time-code", + }) + completeResult <- completeErr + }() + <-runtime.applyStarted + if registration, getErr := store.GetAppRegistration(ctx, started.RegistrationID); getErr != nil || registration == nil { + t.Fatalf("registration before runtime publication = %+v, err %v", registration, getErr) + } + + deleteResult := make(chan error, 1) + go func() { + deleteResult <- lifecycle.Delete(ctx, DefaultUserID, started.RegistrationID) + }() + select { + case deleteErr := <-deleteResult: + t.Fatalf("Delete() completed before runtime publication: %v", deleteErr) + case <-time.After(100 * time.Millisecond): + } + close(runtime.releaseApply) + if err := <-completeResult; err != nil { + t.Fatalf("CompleteManifest(): %v", err) + } + if err := <-deleteResult; err != nil { + t.Fatalf("Delete(): %v", err) + } + if runtime.isActive() { + t.Fatal("deleted registration runtime remains active") + } + registration, err := store.GetAppRegistration(ctx, started.RegistrationID) + if err != nil || registration != nil { + t.Fatalf("registration after delete = %+v, err %v", registration, err) + } +} + +func TestAppRegistrationActivationSurfacesSanitizedCleanupFailure(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + secrets.deleteErr = errors.New("cleanup detail containing private-key") + repository := NewAppRegistrationRepository(store, secrets) + runtime := &failingAppRegistrationRuntime{applyErr: errors.New("activation detail containing client-secret")} + conversion := testManifestConversion(t, "acme", "Organization") + lifecycle := NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ + Repository: repository, Store: store, Runtime: runtime, + Converter: manifestConverterFunc(func(context.Context, string) (ManifestConversionResult, error) { + return conversion, nil + }), + Resolver: manifestResolverFunc(func(context.Context, string, string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("203.0.114.10")}, nil + }), + Random: strings.NewReader(strings.Repeat("s", oauthRandomBytes*3)), + }) + ctx := context.Background() + started, err := lifecycle.StartManifest(ctx, DefaultUserID, AppRegistrationManifestStartRequest{ + WorkspaceID: "workspace-1", DisplayName: "Cleanup failure App", + OwnerType: ManifestOwnerOrganization, OwnerLogin: "acme", + Visibility: AppRegistrationVisibilityPrivate, PublicBaseURL: "https://kandev.example", + }) + if err != nil { + t.Fatal(err) + } + _, err = lifecycle.CompleteManifest(ctx, started.RegistrationID, AppRegistrationManifestCallback{ + State: started.State, Code: "one-time-code", + }) + if err == nil || !strings.Contains(err.Error(), "could not be activated") || + !strings.Contains(err.Error(), "could not be cleaned up") { + t.Fatalf("CompleteManifest() error = %v", err) + } + for _, sensitive := range []string{"private-key", "client-secret"} { + if strings.Contains(err.Error(), sensitive) { + t.Fatalf("CompleteManifest() error leaked %q: %v", sensitive, err) + } + } +} + +func TestAppRegistrationDeleteFailureInvalidatesRuntime(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + registration := newAppRegistration("registration-delete-runtime", 101, "Work", time.Now().UTC()) + if err := repository.SaveRegistration(context.Background(), registration, DeploymentAppCredentials{ + PrivateKey: "private-key", ClientSecret: "client-secret", WebhookSecret: "webhook-secret", + }); err != nil { + t.Fatal(err) + } + secrets.deleteErr = errors.New("delete unavailable") + secrets.deleteErrKey = registration.CredentialSecretID + secrets.deleteThenError = true + secrets.setErr = errors.New("restore unavailable") + secrets.setErrKey = registration.CredentialSecretID + runtime := &failingAppRegistrationRuntime{active: true} + lifecycle := NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ + Repository: repository, Store: store, Runtime: runtime, + }) + err := lifecycle.Delete(context.Background(), DefaultUserID, registration.ID) + if !errors.Is(err, ErrAppRegistrationCredentialCleanup) { + t.Fatalf("Delete() error = %v, want credential cleanup error", err) + } + active, invalidated := runtime.state() + if active || !invalidated { + t.Fatalf("runtime after failed delete: active=%v invalidated=%v", active, invalidated) + } + stored, getErr := store.GetAppRegistration(context.Background(), registration.ID) + if getErr != nil || stored == nil || stored.Status != AppRegistrationStatusInvalid { + t.Fatalf("registration after failed credential restoration = %+v, err %v", stored, getErr) + } +} + +func TestAppRegistrationDeleteFailurePreservesRuntimeAfterCredentialRollback(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + registration := newAppRegistration("registration-delete-runtime-rollback", 101, "Work", time.Now().UTC()) + if err := repository.SaveRegistration(context.Background(), registration, DeploymentAppCredentials{ + PrivateKey: "private-key", ClientSecret: "client-secret", WebhookSecret: "webhook-secret", + }); err != nil { + t.Fatal(err) + } + secrets.deleteErr = errors.New("delete unavailable") + secrets.deleteErrKey = registration.CredentialSecretID + runtime := &failingAppRegistrationRuntime{active: true} + lifecycle := NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ + Repository: repository, Store: store, Runtime: runtime, + }) + err := lifecycle.Delete(context.Background(), DefaultUserID, registration.ID) + if !errors.Is(err, ErrAppRegistrationCredentialCleanup) { + t.Fatalf("Delete() error = %v, want credential cleanup error", err) + } + active, invalidated := runtime.state() + if !active || invalidated { + t.Fatalf("runtime after compensated delete: active=%v invalidated=%v", active, invalidated) + } + stored, getErr := store.GetAppRegistration(context.Background(), registration.ID) + if getErr != nil || stored == nil || stored.Status != AppRegistrationStatusActive { + t.Fatalf("registration after compensated delete = %+v, err %v", stored, getErr) + } +} + +func TestAppRegistrationDeleteFailureInvalidatesRuntimeWhenCredentialAndStatusRestoreFail(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + registration := newAppRegistration("registration-delete-double-failure", 101, "Work", time.Now().UTC()) + if err := repository.SaveRegistration(context.Background(), registration, DeploymentAppCredentials{ + PrivateKey: "private-key", ClientSecret: "client-secret", WebhookSecret: "webhook-secret", + }); err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec(` + CREATE TRIGGER fail_app_registration_invalidation + BEFORE UPDATE OF status ON github_app_registrations + WHEN NEW.status = 'invalid' + BEGIN SELECT RAISE(FAIL, 'invalidation unavailable'); END`); err != nil { + t.Fatal(err) + } + secrets.deleteErr = errors.New("delete unavailable") + secrets.deleteErrKey = registration.CredentialSecretID + secrets.deleteThenError = true + secrets.setErr = errors.New("restore unavailable") + secrets.setErrKey = registration.CredentialSecretID + runtime := &failingAppRegistrationRuntime{active: true} + lifecycle := NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ + Repository: repository, Store: store, Runtime: runtime, + }) + err := lifecycle.Delete(context.Background(), DefaultUserID, registration.ID) + if !errors.Is(err, ErrAppRegistrationCredentialCleanup) || + !errors.Is(err, ErrAppRegistrationDeletionFailed) { + t.Fatalf("Delete() error = %v, want cleanup and deletion errors", err) + } + active, invalidated := runtime.state() + if active || !invalidated { + t.Fatalf("runtime after double failure: active=%v invalidated=%v", active, invalidated) + } + stored, getErr := store.GetAppRegistration(context.Background(), registration.ID) + if getErr != nil || stored == nil || stored.Status != AppRegistrationStatusActive { + t.Fatalf("metadata after failed invalidation = %+v, err %v", stored, getErr) + } + if _, _, loadErr := repository.LoadRegistration(context.Background(), registration.ID); loadErr == nil { + t.Fatal("registration credentials unexpectedly remained readable") + } +} + +func TestAppRegistrationDurableCheckInvalidatesSupersededRuntimeGeneration(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + registration := newAppRegistration("registration-generation-change", 101, "Work", time.Now().UTC()) + if err := repository.SaveRegistration(context.Background(), registration, DeploymentAppCredentials{ + PrivateKey: "private-key-1", ClientSecret: "client-secret-1", WebhookSecret: "webhook-secret-1", + }); err != nil { + t.Fatal(err) + } + previous := *registration + registration.CredentialGeneration++ + if err := repository.SaveRegistration(context.Background(), registration, DeploymentAppCredentials{ + PrivateKey: "private-key-2", ClientSecret: "client-secret-2", WebhookSecret: "webhook-secret-2", + }); err != nil { + t.Fatal(err) + } + runtime := &failingAppRegistrationRuntime{active: true} + lifecycle := NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ + Repository: repository, Store: store, Runtime: runtime, + }) + lifecycle.invalidateRuntimeForUnusableRegistration(context.Background(), &previous) + active, invalidated := runtime.state() + if active || !invalidated { + t.Fatalf("superseded runtime state: active=%v invalidated=%v", active, invalidated) + } +} + +func TestHTTPAppRegistrationDuplicateImportReturnsExistingID(t *testing.T) { + router, _, store := setupAppRegistrationController(t) + seedConnectionWorkspaces(t, store, "workspace-1") + existing := newAppRegistration("registration-existing", 101, "Existing", time.Now().UTC()) + if err := store.UpsertDeploymentAppRegistration(context.Background(), existing); err != nil { + t.Fatal(err) + } + prepare := serveRegistrationJSON(t, router, http.MethodPost, + "/api/v1/github/app/registrations/import/prepare", + `{"workspace_id":"workspace-1","public_base_url":"https://kandev.example"}`) + if prepare.Code != http.StatusCreated { + t.Fatalf("prepare = %d %s", prepare.Code, prepare.Body.String()) + } + var preparation AppRegistrationImportPreparationResult + if err := json.Unmarshal(prepare.Body.Bytes(), &preparation); err != nil { + t.Fatal(err) + } + response := serveRegistrationJSON(t, router, http.MethodPost, + "/api/v1/github/app/registrations/import", `{ + "registration_id":"`+preparation.RegistrationID+`","workspace_id":"workspace-1","display_name":"Duplicate","github_host":"github.com", + "app_id":101,"client_id":"Iv1.client","client_secret":"client-secret", + "private_key":"private-key","webhook_secret":"webhook-secret","slug":"kandev-app", + "owner_login":"acme","owner_type":"Organization","visibility":"private", + "public_base_url":"https://kandev.example" + }`) + if response.Code != http.StatusConflict || + !strings.Contains(response.Body.String(), `"code":"github_app_already_registered"`) || + !strings.Contains(response.Body.String(), `"existing_registration_id":"registration-existing"`) { + t.Fatalf("duplicate import = %d %s", response.Code, response.Body.String()) + } + for _, secret := range []string{"client-secret", "private-key", "webhook-secret"} { + if strings.Contains(response.Body.String(), secret) { + t.Fatalf("duplicate response leaked %q", secret) + } + } +} + +func TestHTTPAppRegistrationImportPreparationIsSingleUse(t *testing.T) { + router, _, store := setupAppRegistrationController(t) + seedConnectionWorkspaces(t, store, "workspace-1") + prepare := serveRegistrationJSON(t, router, http.MethodPost, + "/api/v1/github/app/registrations/import/prepare", + `{"workspace_id":"workspace-1","public_base_url":"https://KANDEV.example:443/"}`) + if prepare.Code != http.StatusCreated || prepare.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("prepare = %d headers=%v body=%s", prepare.Code, prepare.Header(), prepare.Body.String()) + } + var preparation AppRegistrationImportPreparationResult + if err := json.Unmarshal(prepare.Body.Bytes(), &preparation); err != nil { + t.Fatal(err) + } + base := "https://kandev.example/api/v1/github/app/registrations/" + preparation.RegistrationID + if preparation.PublicBaseURL != "https://kandev.example" || + preparation.ManifestCallbackURL != base+"/manifest/callback" || + preparation.InstallCallbackURL != base+"/install/callback" || + preparation.PersonalCallbackURL != base+"/personal/callback" || + preparation.WebhookURL != base+"/webhook" || preparation.SetupURL != preparation.InstallCallbackURL || + preparation.Permissions["contents"] != "write" || !containsString(preparation.Events, "installation") { + t.Fatalf("preparation = %+v", preparation) + } + _, privateKey := testAppPrivateKey(t) + request := AppRegistrationImportRequest{ + RegistrationID: preparation.RegistrationID, WorkspaceID: "workspace-1", + DisplayName: "Existing App", GitHubHost: defaultGitHubAppHost, AppID: 101, + ClientID: "Iv1.client", ClientSecret: "client-secret", PrivateKey: string(privateKey), + WebhookSecret: "webhook-secret", Slug: "kandev-app", OwnerLogin: "acme", + OwnerType: AppRegistrationOwnerOrganization, Visibility: AppRegistrationVisibilityPrivate, + PublicBaseURL: preparation.PublicBaseURL, + } + body, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + imported := serveRegistrationJSON(t, router, http.MethodPost, + "/api/v1/github/app/registrations/import", string(body)) + if imported.Code != http.StatusCreated || !strings.Contains(imported.Body.String(), preparation.RegistrationID) { + t.Fatalf("import = %d %s", imported.Code, imported.Body.String()) + } + replayed := serveRegistrationJSON(t, router, http.MethodPost, + "/api/v1/github/app/registrations/import", string(body)) + if replayed.Code != http.StatusBadRequest || + !strings.Contains(replayed.Body.String(), `"code":"github_app_import_preparation_invalid"`) { + t.Fatalf("replay = %d %s", replayed.Code, replayed.Body.String()) + } + for _, secret := range []string{request.ClientSecret, request.PrivateKey, request.WebhookSecret} { + if strings.Contains(replayed.Body.String(), secret) { + t.Fatalf("replay response leaked credential %q", secret) + } + } +} + +func TestHTTPAppRegistrationImportWithoutPreparationFailsWithoutSecretLeak(t *testing.T) { + router, _, store := setupAppRegistrationController(t) + seedConnectionWorkspaces(t, store, "workspace-1") + _, privateKey := testAppPrivateKey(t) + request := AppRegistrationImportRequest{ + RegistrationID: uuid.NewString(), WorkspaceID: "workspace-1", + DisplayName: "Unprepared App", GitHubHost: defaultGitHubAppHost, AppID: 101, + ClientID: "Iv1.client", ClientSecret: "unprepared-client-secret", + PrivateKey: string(privateKey), WebhookSecret: "unprepared-webhook-secret", + Slug: "kandev-app", OwnerLogin: "acme", OwnerType: AppRegistrationOwnerOrganization, + Visibility: AppRegistrationVisibilityPrivate, PublicBaseURL: "https://kandev.example", + } + body, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + response := serveRegistrationJSON(t, router, http.MethodPost, + "/api/v1/github/app/registrations/import", string(body)) + if response.Code != http.StatusBadRequest || + !strings.Contains(response.Body.String(), `"code":"github_app_import_preparation_invalid"`) { + t.Fatalf("unprepared import = %d %s", response.Code, response.Body.String()) + } + for _, secret := range []string{request.ClientSecret, request.PrivateKey, request.WebhookSecret} { + if strings.Contains(response.Body.String(), secret) { + t.Fatalf("unprepared response leaked credential %q", secret) + } + } +} + +func TestAppRegistrationCatalogSharedCountsWorkspaceBindingsOnly(t *testing.T) { + _, service, store := setupAppRegistrationController(t) + seedConnectionWorkspaces(t, store, "workspace-1", "workspace-2") + ctx := context.Background() + now := time.Now().UTC() + registration := newAppRegistration("registration-shared", 101, "Shared App", now) + if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil { + t.Fatal(err) + } + installationID := int64(42) + connection := func(workspaceID string) *WorkspaceConnection { + return &WorkspaceConnection{ + WorkspaceID: workspaceID, Source: ConnectionSourceGitHubAppInstallation, + GitHubHost: defaultGitHubHost, InstallationID: &installationID, + InstallationAccountLogin: "acme", InstallationAccountType: "Organization", + AppRegistrationID: registration.ID, Status: ConnectionStatusActive, + CredentialGeneration: 1, + } + } + if err := store.UpsertWorkspaceConnection(ctx, connection("workspace-1")); err != nil { + t.Fatal(err) + } + if err := store.UpsertUserConnection(ctx, &UserConnection{ + WorkspaceID: "workspace-1", UserID: DefaultUserID, AppRegistrationID: registration.ID, + GitHubUserID: 7, Login: "octocat", Status: ConnectionStatusActive, + AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + }); err != nil { + t.Fatal(err) + } + catalog, err := service.ListAppRegistrationCatalog(ctx, DefaultUserID, "workspace-1") + if err != nil || len(catalog.Registrations) != 1 { + t.Fatalf("single-workspace catalog = %+v, err %v", catalog, err) + } + item := catalog.Registrations[0] + if item.BindingCount != 2 || item.WorkspaceBindingCount != 1 || item.Shared { + t.Fatalf("single-workspace item = %+v", item) + } + if err := store.UpsertWorkspaceConnection(ctx, connection("workspace-2")); err != nil { + t.Fatal(err) + } + catalog, err = service.ListAppRegistrationCatalog(ctx, DefaultUserID, "workspace-1") + if err != nil || len(catalog.Registrations) != 1 { + t.Fatalf("shared catalog = %+v, err %v", catalog, err) + } + item = catalog.Registrations[0] + if item.BindingCount != 3 || item.WorkspaceBindingCount != 2 || !item.Shared { + t.Fatalf("shared item = %+v", item) + } +} + +func TestLegacySingletonGitHubAppRoutesAreRemoved(t *testing.T) { + router, _, _ := setupAppRegistrationController(t) + for _, route := range []string{ + "/api/v1/github/app/registration", + "/api/v1/github/app/registration/callback", + "/api/v1/github/app/callback", + "/api/v1/github/personal/callback", + } { + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, route, nil)) + if response.Code != http.StatusNotFound { + t.Errorf("GET %s = %d, want 404", route, response.Code) + } + } +} + +func TestGitHubAuthCallbackErrorsReturnToInitiatingWorkspace(t *testing.T) { + router, _, store := setupAppRegistrationController(t) + seedConnectionWorkspaces(t, store, "workspace-1") + registration := newAppRegistration("registration-work", 101, "Work", time.Now().UTC()) + if err := store.UpsertDeploymentAppRegistration(context.Background(), registration); err != nil { + t.Fatal(err) + } + + for _, test := range []struct { + name string + kind AuthFlowKind + path string + }{ + {name: "installation", kind: AuthFlowKindAppInstallation, + path: "/api/v1/github/app/registrations/wrong-registration/install/callback?installation_id=42"}, + {name: "personal", kind: AuthFlowKindPersonal, + path: "/api/v1/github/app/registrations/wrong-registration/personal/callback?code=oauth-code"}, + } { + t.Run(test.name, func(t *testing.T) { + flows := NewOAuthFlowManager(store) + flows.random = strings.NewReader(strings.Repeat(test.name, oauthRandomBytes*4)) + started, err := flows.Start(context.Background(), OAuthFlowRequest{ + WorkspaceID: "workspace-1", UserID: DefaultUserID, + AppRegistrationID: registration.ID, Kind: test.kind, + }) + if err != nil { + t.Fatal(err) + } + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, + test.path+"&state="+started.State, nil)) + assertPrivateGitHubRedirect(t, response) + if location := response.Header().Get("Location"); !strings.Contains(location, "workspace_id=workspace-1") || + !strings.Contains(location, "github_result=github_not_configured") { + t.Fatalf("callback location = %q", location) + } + flow, err := store.GetAuthFlow(context.Background(), stateDigest(started.State)) + if err != nil || flow == nil || flow.ConsumedAt != nil { + t.Fatalf("wrong route consumed flow: flow=%+v err=%v", flow, err) + } + }) + } +} + +func serveRegistrationJSON( + t *testing.T, + router http.Handler, + method, path, body string, +) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, path, strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +func assertPrivateGitHubRedirect(t *testing.T, response *httptest.ResponseRecorder) { + t.Helper() + if response.Code != http.StatusSeeOther || response.Header().Get("Cache-Control") != "no-store" || + response.Header().Get("Referrer-Policy") != "no-referrer" { + t.Fatalf("callback = %d headers=%v body=%s", response.Code, response.Header(), response.Body.String()) + } + if location := response.Header().Get("Location"); strings.Contains(location, "state=") || strings.Contains(location, "code=") { + t.Fatalf("callback leaked provider parameters: %q", location) + } +} diff --git a/apps/backend/internal/github/controller_auth.go b/apps/backend/internal/github/controller_auth.go new file mode 100644 index 0000000000..b0cc52a7a4 --- /dev/null +++ b/apps/backend/internal/github/controller_auth.go @@ -0,0 +1,390 @@ +package github + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/auth/authn" +) + +const defaultGitHubUserID = DefaultUserID + +const githubNotConfiguredCode = "github_not_configured" + +func currentGitHubUserID(ctx *gin.Context) string { + return githubUserID(ctx.Request.Context()) +} + +func githubUserID(ctx context.Context) string { + if identity, ok := authn.IdentityFromContext(ctx); ok && strings.TrimSpace(identity.UserID) != "" { + return identity.UserID + } + return defaultGitHubUserID +} + +func (c *Controller) httpGetWorkspaceConnection(ctx *gin.Context) { + status, err := c.service.GetWorkspaceAuthStatus( + ctx.Request.Context(), ctx.Query("workspace_id"), currentGitHubUserID(ctx), + ) + if err != nil { + writeGitHubAuthError(ctx, err) + return + } + ctx.JSON(http.StatusOK, status) +} + +func (c *Controller) httpListCLIAccounts(ctx *gin.Context) { + if err := requireGHCLIOperator(ctx.Request.Context()); err != nil { + writeGitHubAuthError(ctx, err) + return + } + accounts, err := c.service.ListGHAccounts(ctx.Request.Context()) + if err != nil { + writeGitHubAuthError(ctx, err) + return + } + workspaceID := strings.TrimSpace(ctx.Query("workspace_id")) + if workspaceID != "" { + status, statusErr := c.service.GetWorkspaceAuthStatus( + ctx.Request.Context(), workspaceID, currentGitHubUserID(ctx), + ) + if statusErr != nil { + writeGitHubAuthError(ctx, statusErr) + return + } + if status.Automation != nil && status.Automation.Source == ConnectionSourceGHCLI { + for index := range accounts { + accounts[index].Selected = strings.EqualFold(accounts[index].Host, status.Automation.GitHubHost) && + strings.EqualFold(accounts[index].Login, status.Automation.Login) + } + } + } + ctx.JSON(http.StatusOK, gin.H{"accounts": accounts}) +} + +func (c *Controller) httpSetWorkspaceConnection(ctx *gin.Context) { + var request SetWorkspaceConnectionRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"code": "github_invalid_request", "error": "invalid payload"}) + return + } + connection, err := c.service.SetWorkspaceConnection( + ctx.Request.Context(), workspaceIDFromRouteOrQuery(ctx), request, + ) + if err != nil { + writeGitHubAuthError(ctx, err) + return + } + ctx.JSON(http.StatusOK, connection) +} + +func (c *Controller) httpDeleteWorkspaceConnection(ctx *gin.Context) { + if err := c.service.DeleteWorkspaceConnection( + ctx.Request.Context(), workspaceIDFromRouteOrQuery(ctx), + ); err != nil { + writeGitHubAuthError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"disconnected": true}) +} + +func (c *Controller) httpStartAppInstallation(ctx *gin.Context) { + var request struct { + WorkspaceID string `json:"workspace_id"` + AppRegistrationID string `json:"app_registration_id"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || + strings.TrimSpace(request.WorkspaceID) == "" || + strings.TrimSpace(request.AppRegistrationID) == "" { + ctx.JSON(http.StatusBadRequest, gin.H{ + "code": "github_app_invalid_request", "error": "workspace and App registration are required", + }) + return + } + result, err := c.service.StartAppInstallation( + ctx.Request.Context(), strings.TrimSpace(request.WorkspaceID), currentGitHubUserID(ctx), + strings.TrimSpace(request.AppRegistrationID), + ) + if err != nil { + writeGitHubAuthError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpCompleteAppInstallation(ctx *gin.Context) { + if !validGitHubCallbackState(ctx.Query("state")) { + redirectGitHubCallback(ctx, "", "github_invalid_callback") + return + } + workspaceID := c.authFlowWorkspace(ctx, ctx.Query("state")) + installationID, err := strconv.ParseInt(ctx.Query("installation_id"), 10, 64) + if err != nil { + redirectGitHubCallback(ctx, workspaceID, "github_invalid_callback") + return + } + result, err := c.service.CompleteAppInstallation( + ctx.Request.Context(), ctx.Param("registrationId"), AppInstallationCallback{ + State: ctx.Query("state"), + Code: ctx.Query("code"), + SetupAction: ctx.Query("setup_action"), + InstallationID: installationID, + }) + if err != nil { + redirectGitHubCallback(ctx, workspaceID, githubAuthErrorCode(err)) + return + } + redirectGitHubCallback(ctx, result.WorkspaceID, "app_connected") +} + +func (c *Controller) httpStartPersonalAuth(ctx *gin.Context) { + workspaceID, ok := setupWorkspaceID(ctx) + if !ok { + return + } + result, err := c.service.StartPersonalAuth( + ctx.Request.Context(), workspaceID, currentGitHubUserID(ctx), + ) + if err != nil { + writeGitHubAuthError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpCompletePersonalAuth(ctx *gin.Context) { + if !validGitHubCallbackState(ctx.Query("state")) { + redirectGitHubCallback(ctx, "", "github_invalid_callback") + return + } + workspaceID := c.authFlowWorkspace(ctx, ctx.Query("state")) + result, err := c.service.CompletePersonalAuth( + ctx.Request.Context(), ctx.Param("registrationId"), PersonalAuthCallback{ + State: ctx.Query("state"), + Code: ctx.Query("code"), + }) + if err != nil { + redirectGitHubCallback(ctx, workspaceID, githubAuthErrorCode(err)) + return + } + redirectGitHubCallback(ctx, result.WorkspaceID, "personal_connected") +} + +func (c *Controller) authFlowWorkspace(ctx *gin.Context, state string) string { + if c == nil || c.service == nil || c.service.store == nil { + return "" + } + digest := sha256.Sum256([]byte(strings.TrimSpace(state))) + flow, err := c.service.store.GetAuthFlow(ctx.Request.Context(), stateDigestString(digest)) + if err != nil || flow == nil { + return "" + } + return flow.WorkspaceID +} + +func validGitHubCallbackState(state string) bool { + trimmed := strings.TrimSpace(state) + if len(trimmed) != base64.RawURLEncoding.EncodedLen(oauthRandomBytes) { + return false + } + decoded, err := base64.RawURLEncoding.DecodeString(trimmed) + return err == nil && len(decoded) == oauthRandomBytes +} + +func (c *Controller) httpDisconnectPersonalAuth(ctx *gin.Context) { + if err := c.service.DisconnectPersonalAuth( + ctx.Request.Context(), workspaceIDFromRouteOrQuery(ctx), currentGitHubUserID(ctx), + ); err != nil { + writeGitHubAuthError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"disconnected": true}) +} + +func (c *Controller) httpGitHubAppWebhook(ctx *gin.Context) { + payload, err := io.ReadAll(io.LimitReader(ctx.Request.Body, maxGitHubWebhookPayloadSize+1)) + if err != nil || len(payload) > maxGitHubWebhookPayloadSize { + ctx.JSON(http.StatusBadRequest, gin.H{"code": "github_invalid_webhook", "error": "invalid webhook payload"}) + return + } + result, err := c.service.HandleAppRegistrationWebhook( + ctx.Request.Context(), ctx.Param("registrationId"), GitHubWebhookRequest{ + DeliveryID: ctx.GetHeader("X-GitHub-Delivery"), + Event: ctx.GetHeader("X-GitHub-Event"), + Signature: ctx.GetHeader("X-Hub-Signature-256"), + Payload: payload, + }) + if err != nil { + writeGitHubAuthError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpResolveCredentialLease(ctx *gin.Context) { + var request struct { + Lease string `json:"lease"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + RepositoryID string `json:"repository_id"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Host string `json:"host"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"code": "github_invalid_request", "error": "invalid payload"}) + return + } + credential, err := c.service.ResolveGitHubCredential(ctx.Request.Context(), BrokerCredentialRequest{ + Lease: request.Lease, TaskID: request.TaskID, SessionID: request.SessionID, + RepositoryID: request.RepositoryID, Owner: request.Owner, Repo: request.Repo, Host: request.Host, + }) + if err != nil { + writeGitHubAuthError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, credential) +} + +func (c *Controller) httpCredentialBrokerReady(ctx *gin.Context) { + if c == nil || c.service == nil || !c.service.CredentialBrokerReady() { + ctx.Status(http.StatusServiceUnavailable) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.Status(http.StatusNoContent) +} + +func writeGitHubAuthError(ctx *gin.Context, err error) { + status, code := githubAuthErrorResponse(err) + message := strings.TrimSpace(err.Error()) + payload := gin.H{"code": code, "error": message} + var importError *AppRegistrationImportError + if errors.As(err, &importError) { + if importError.ExistingRegistrationID != "" { + payload["existing_registration_id"] = importError.ExistingRegistrationID + } + if len(importError.Problems) > 0 { + payload["problems"] = importError.Problems + } + } + ctx.JSON(status, payload) +} + +func githubAuthErrorCode(err error) string { + _, code := githubAuthErrorResponse(err) + return code +} + +func githubAuthErrorResponse(err error) (int, string) { + if status, code, ok := deploymentAppHTTPError(err); ok { + return status, code + } + status, code := http.StatusInternalServerError, "github_internal_error" + switch { + case errors.Is(err, ErrGitHubWorkspaceRequired): + status, code = http.StatusBadRequest, "github_workspace_required" + case errors.Is(err, ErrGitHubNotConfigured): + status, code = http.StatusConflict, githubNotConfiguredCode + case errors.Is(err, ErrWorkspaceConnectionStale): + status, code = http.StatusConflict, "github_connection_changed" + case errors.Is(err, ErrGitHubPersonalRequired): + status, code = http.StatusConflict, "github_personal_required" + case errors.Is(err, ErrGitHubCapabilityDenied): + status, code = http.StatusForbidden, "github_capability_denied" + case errors.Is(err, ErrGHCLIOperatorRequired): + status, code = http.StatusForbidden, "github_cli_operator_required" + case errors.Is(err, ErrGitHubConnectionInvalid), errors.Is(err, ErrPersonalTokenInvalid): + status, code = http.StatusUnauthorized, "github_reconnect_required" + case errors.Is(err, ErrOAuthStateInvalid), errors.Is(err, ErrOAuthStateMismatch), + errors.Is(err, ErrInstallationAssociationUnverified): + status, code = http.StatusBadRequest, "github_invalid_callback" + case errors.Is(err, ErrInvalidWebhookSignature): + status, code = http.StatusUnauthorized, "github_invalid_webhook_signature" + case errors.Is(err, ErrCredentialLeaseInvalid), errors.Is(err, ErrCredentialLeaseExpired), + errors.Is(err, ErrCredentialLeaseRevoked), errors.Is(err, ErrCredentialScopeDenied): + status, code = http.StatusUnauthorized, "github_credential_denied" + case errors.Is(err, ErrInvalidToken): + status, code = http.StatusBadRequest, "github_invalid_token" + } + return status, code +} + +func isGitHubNotConfiguredError(err error) bool { + return errors.Is(err, ErrNoClient) || errors.Is(err, ErrGitHubNotConfigured) +} + +func writeGitHubOperationalAuthError(ctx *gin.Context, err error) bool { + if isGitHubNotConfiguredError(err) { + ctx.JSON(http.StatusServiceUnavailable, gin.H{ + "code": githubNotConfiguredCode, + "error": "GitHub is not configured. Connect GitHub in workspace settings.", + }) + return true + } + if errors.Is(err, ErrRepoNotResolvable) { + // A repository outside the workspace scope is indistinguishable from a + // missing repository. Do not leak the workspace's authorization details. + ctx.JSON(http.StatusNotFound, gin.H{ + "code": "github_repository_not_found", + "error": "GitHub repository is not available in this workspace.", + }) + return true + } + for _, target := range []error{ + ErrGitHubWorkspaceRequired, + ErrGitHubPersonalRequired, + ErrGitHubConnectionInvalid, + ErrPersonalTokenInvalid, + ErrGitHubCapabilityDenied, + } { + if errors.Is(err, target) { + writeGitHubAuthError(ctx, err) + return true + } + } + return false +} + +func workspaceIDFromRouteOrQuery(ctx *gin.Context) string { + if workspaceID := strings.TrimSpace(ctx.Param("workspaceId")); workspaceID != "" { + return workspaceID + } + return strings.TrimSpace(ctx.Query("workspace_id")) +} + +func setupWorkspaceID(ctx *gin.Context) (string, bool) { + if workspaceID := workspaceIDFromRouteOrQuery(ctx); workspaceID != "" { + return workspaceID, true + } + var request struct { + WorkspaceID string `json:"workspace_id"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.WorkspaceID) == "" { + ctx.JSON(http.StatusBadRequest, gin.H{ + "code": "github_workspace_required", "error": ErrGitHubWorkspaceRequired.Error(), + }) + return "", false + } + return strings.TrimSpace(request.WorkspaceID), true +} + +func redirectGitHubCallback(ctx *gin.Context, workspaceID, result string) { + query := url.Values{"github_result": []string{result}} + if workspaceID != "" { + query.Set("workspace_id", workspaceID) + } + ctx.Header("Cache-Control", "no-store") + ctx.Header("Referrer-Policy", "no-referrer") + ctx.Redirect(http.StatusSeeOther, "/settings/integrations/github?"+query.Encode()) +} diff --git a/apps/backend/internal/github/controller_auth_identity_test.go b/apps/backend/internal/github/controller_auth_identity_test.go new file mode 100644 index 0000000000..9d0b11f3b4 --- /dev/null +++ b/apps/backend/internal/github/controller_auth_identity_test.go @@ -0,0 +1,75 @@ +package github + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/auth/authn" +) + +func TestCurrentGitHubUserIDUsesRequestIdentity(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil) + authn.SetOnGin(ctx, authn.Identity{UserID: "member-1", Role: authn.RoleMember}) + + if got := currentGitHubUserID(ctx); got != "member-1" { + t.Fatalf("currentGitHubUserID() = %q, want request identity", got) + } +} + +func TestGHCLIOperatorRoutesDenyMembersWithoutLeakingHostAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + service, _ := newWorkspaceConnectionService(t, "operator") + service.ghAccountLister = func(_ context.Context) ([]GHAccount, error) { + return []GHAccount{{Host: "github.com", Login: "host-account"}}, nil + } + router := gin.New() + NewController(service, testLogger(t)).RegisterHTTPRoutes(router) + + member := authn.Identity{UserID: "member-1", Role: authn.RoleMember} + for _, request := range []*http.Request{ + httptest.NewRequest(http.MethodGet, "/api/v1/github/auth/gh-cli/accounts", nil), + httptest.NewRequest(http.MethodPut, "/api/v1/github/workspace-connection?workspace_id=ws-1", + bytes.NewBufferString(`{"source":"gh_cli","host":"github.com","login":"host-account"}`)), + } { + request = request.WithContext(authn.WithIdentity(request.Context(), member)) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("member response = %d %s, want sanitized 403", response.Code, response.Body.String()) + } + if bytes.Contains(response.Body.Bytes(), []byte("host-account")) || + bytes.Contains(response.Body.Bytes(), []byte("github.com")) { + t.Fatalf("member denial leaked host account: %s", response.Body.String()) + } + } +} + +func TestGHCLIOperatorAccountListingAllowsAdminAndSyntheticIdentity(t *testing.T) { + gin.SetMode(gin.TestMode) + service, _ := newWorkspaceConnectionService(t, "operator") + service.ghAccountLister = func(_ context.Context) ([]GHAccount, error) { + return []GHAccount{{Host: "github.com", Login: "host-account"}}, nil + } + router := gin.New() + NewController(service, testLogger(t)).RegisterHTTPRoutes(router) + + for _, identity := range []authn.Identity{ + {UserID: "admin-1", Role: authn.RoleAdmin}, + {UserID: DefaultUserID, Role: authn.RoleAdmin, Synthetic: true}, + } { + request := httptest.NewRequest(http.MethodGet, "/api/v1/github/auth/gh-cli/accounts", nil) + request = request.WithContext(authn.WithIdentity(request.Context(), identity)) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK || !bytes.Contains(response.Body.Bytes(), []byte("host-account")) { + t.Fatalf("identity %#v listing = %d %s, want account", identity, response.Code, response.Body.String()) + } + } +} diff --git a/apps/backend/internal/github/controller_test.go b/apps/backend/internal/github/controller_test.go index 8566a4003f..94e02d73d5 100644 --- a/apps/backend/internal/github/controller_test.go +++ b/apps/backend/internal/github/controller_test.go @@ -3,6 +3,7 @@ package github import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -17,12 +18,34 @@ import ( "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" taskmodels "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" taskservice "github.com/kandev/kandev/internal/task/service" + ws "github.com/kandev/kandev/pkg/websocket" ) +func TestValidGitHubCallbackState(t *testing.T) { + valid := base64.RawURLEncoding.EncodeToString(make([]byte, oauthRandomBytes)) + for name, test := range map[string]struct { + state string + want bool + }{ + "valid": {state: valid, want: true}, + "empty": {state: "", want: false}, + "malformed": {state: "%%%", want: false}, + "wrong decoded size": {state: base64.RawURLEncoding.EncodeToString([]byte("short")), want: false}, + } { + t.Run(name, func(t *testing.T) { + if got := validGitHubCallbackState(test.state); got != test.want { + t.Fatalf("validGitHubCallbackState(%q) = %v, want %v", test.state, got, test.want) + } + }) + } +} + // stubClient implements Client with no-op defaults; override fields as needed. type stubClient struct { getPRFunc func(ctx context.Context, owner, repo string, number int) (*PR, error) @@ -73,6 +96,9 @@ func (s *stubClient) ListUserRepos(context.Context, string, int) ([]GitHubRepo, func (s *stubClient) ListAccessibleRepos(context.Context, string, int) ([]GitHubRepo, error) { return nil, nil } +func (s *stubClient) HasRepositoryAccess(context.Context, string, string) (bool, error) { + return true, nil +} func (s *stubClient) ListPRReviews(context.Context, string, string, int) ([]PRReview, error) { return nil, nil } @@ -147,12 +173,61 @@ func newControllerTestLogger() *logger.Logger { return log } +type controllerTestConnectionReader struct { + connection *WorkspaceConnection +} + +func (r controllerTestConnectionReader) GetWorkspaceConnection( + context.Context, + string, +) (*WorkspaceConnection, error) { + return r.connection, nil +} + +func (controllerTestConnectionReader) GetUserConnection( + context.Context, + string, + string, +) (*UserConnection, error) { + return nil, nil +} + +func useControllerTestWorkspace(router *gin.Engine) { + router.Use(func(ctx *gin.Context) { + query := ctx.Request.URL.Query() + if query.Get("workspace_id") == "" && ctx.GetHeader("X-Test-Omit-Workspace") == "" { + query.Set("workspace_id", "ws-1") + ctx.Request.URL.RawQuery = query.Encode() + } + ctx.Next() + }) +} + +func configureControllerTestResolver(svc *Service, client Client, workspaceID string) { + resolver := NewCredentialResolver(controllerTestConnectionReader{connection: &WorkspaceConnection{ + WorkspaceID: workspaceID, + Source: ConnectionSourceLegacyShared, + GitHubHost: defaultGitHubHost, + Status: ConnectionStatusActive, + CredentialGeneration: 1, + }}, nil) + resolver.SetLegacyFactory(func(context.Context) (Client, string, error) { + if client == nil { + return nil, AuthMethodNone, ErrGitHubNotConfigured + } + return client, AuthMethodPAT, nil + }) + svc.resolver = resolver +} + func setupControllerTest(client Client) (*gin.Engine, *Controller) { gin.SetMode(gin.TestMode) log := newControllerTestLogger() svc := NewService(client, "pat", nil, nil, nil, log) + configureControllerTestResolver(svc, client, "ws-1") ctrl := NewController(svc, log) router := gin.New() + useControllerTestWorkspace(router) ctrl.RegisterHTTPRoutes(router) return router, ctrl } @@ -178,7 +253,11 @@ func setupControllerStoreTest(t *testing.T) (*gin.Engine, *Store) { } sqlxDB := sqlx.NewDb(dbConn, "sqlite3") t.Cleanup(func() { _ = sqlxDB.Close() }) - if _, err := sqlxDB.Exec(`CREATE TABLE tasks ( + if _, err := sqlxDB.Exec(`CREATE TABLE workspaces ( + id TEXT PRIMARY KEY + ); + INSERT INTO workspaces (id) VALUES ('ws-1'); + CREATE TABLE tasks ( id TEXT PRIMARY KEY, workspace_id TEXT, title TEXT NOT NULL DEFAULT '', @@ -194,13 +273,45 @@ func setupControllerStoreTest(t *testing.T) (*gin.Engine, *Store) { } log := newControllerTestLogger() svc := NewService(&stubClient{}, "pat", nil, store, nil, log) + if err := store.UpsertWorkspaceConnection(context.Background(), &WorkspaceConnection{ + WorkspaceID: "ws-1", + Source: ConnectionSourceLegacyShared, + GitHubHost: defaultGitHubHost, + Status: ConnectionStatusActive, + CredentialGeneration: 1, + }); err != nil { + t.Fatalf("seed workspace connection: %v", err) + } + svc.resolver.SetLegacyFactory(func(context.Context) (Client, string, error) { + return svc.client, AuthMethodPAT, nil + }) svc.SetPromptResolver(staticPromptResolver{content: "resolved default prompt"}) ctrl := NewController(svc, log) router := gin.New() + useControllerTestWorkspace(router) ctrl.RegisterHTTPRoutes(router) return router, store } +func TestCredentialBrokerReadinessUsesExactResolveRoute(t *testing.T) { + router, controller := setupControllerTest(&stubClient{}) + + request := httptest.NewRequest(http.MethodGet, "/api/v1/github/credentials/resolve", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("unconfigured readiness status = %d, want %d", response.Code, http.StatusServiceUnavailable) + } + + controller.service.SetCredentialBroker(&CredentialBroker{}) + request = httptest.NewRequest(http.MethodGet, "/api/v1/github/credentials/resolve", nil) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusNoContent || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("configured readiness response = %d headers=%v", response.Code, response.Header()) + } +} + func TestHttpListTaskIssues_WorkspaceScopedMetadataLinks(t *testing.T) { router, store := setupControllerStoreTest(t) rows := []struct { @@ -291,6 +402,7 @@ func TestHttpListTaskIssues_WorkspaceScopedMetadataLinks(t *testing.T) { func TestHttpListTaskIssues_RequiresWorkspace(t *testing.T) { router, _ := setupControllerStoreTest(t) req := httptest.NewRequest(http.MethodGet, "/api/v1/github/task-issues", nil) + req.Header.Set("X-Test-Omit-Workspace", "true") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { @@ -343,6 +455,7 @@ func TestHttpTriggerReviewWatchPublishesNewPREvents(t *testing.T) { log := newControllerTestLogger() eb := &mockEventBus{} svc := NewService(client, "pat", nil, store, eb, log) + configureControllerTestResolver(svc, client, "ws-1") router := gin.New() NewController(svc, log).RegisterHTTPRoutes(router) @@ -368,6 +481,161 @@ func TestHttpTriggerReviewWatchPublishesNewPREvents(t *testing.T) { } } +func TestHttpListReviewWatchesRequiresWorkspaceForRequests(t *testing.T) { + store := newTestStore(t) + watch := &ReviewWatch{WorkspaceID: "victim-workspace", Prompt: "secret review prompt"} + if err := store.CreateReviewWatch(context.Background(), watch); err != nil { + t.Fatal(err) + } + svc := NewService(nil, AuthMethodNone, nil, store, nil, newControllerTestLogger()) + router := gin.New() + NewController(svc, newControllerTestLogger()).RegisterHTTPRoutes(router) + + foreign := httptest.NewRequest(http.MethodGet, "/api/v1/github/watches/review", nil) + foreign = foreign.WithContext(authn.WithIdentity(foreign.Context(), authn.Identity{ + UserID: "foreign-member", Role: authn.RoleMember, + })) + foreignResponse := httptest.NewRecorder() + router.ServeHTTP(foreignResponse, foreign) + if foreignResponse.Code != http.StatusBadRequest || strings.Contains(foreignResponse.Body.String(), watch.Prompt) { + t.Fatalf("foreign response = %d %s, want no unscoped watch disclosure", foreignResponse.Code, foreignResponse.Body.String()) + } + + for _, identity := range []authn.Identity{ + {UserID: "owner-member", Role: authn.RoleMember}, + {UserID: "owner-admin", Role: authn.RoleAdmin}, + } { + req := httptest.NewRequest(http.MethodGet, + "/api/v1/github/watches/review?workspace_id=victim-workspace", nil) + req = req.WithContext(authn.WithIdentity(req.Context(), identity)) + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), watch.Prompt) { + t.Fatalf("%s response = %d %s, want scoped watch", identity.Role, response.Code, response.Body.String()) + } + } +} + +func TestHttpReviewWatchMutationMapsForeignWorkspaceToNotFound(t *testing.T) { + store := newTestStore(t) + watch := &ReviewWatch{WorkspaceID: "victim-workspace", Prompt: "secret review prompt"} + if err := store.CreateReviewWatch(context.Background(), watch); err != nil { + t.Fatal(err) + } + svc := NewService(nil, AuthMethodNone, nil, store, nil, newControllerTestLogger()) + svc.SetWorkspaceAuthorizer(func(context.Context, string) error { return repoerrors.ErrWorkspaceNotFound }) + router := gin.New() + NewController(svc, newControllerTestLogger()).RegisterHTTPRoutes(router) + + req := httptest.NewRequest(http.MethodDelete, + "/api/v1/github/watches/review/"+watch.ID+"?workspace_id=victim-workspace", nil) + req = req.WithContext(authn.WithIdentity(req.Context(), authn.Identity{UserID: "foreign", Role: authn.RoleMember})) + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + if response.Code != http.StatusNotFound || strings.Contains(response.Body.String(), "workspace") { + t.Fatalf("foreign mutation response = %d %s, want sanitized 404", response.Code, response.Body.String()) + } +} + +func TestHTTPWatchListsRequireWorkspace(t *testing.T) { + store := newTestStore(t) + issueWatch := &IssueWatch{WorkspaceID: "victim-workspace", Prompt: "secret issue prompt"} + if err := store.CreateIssueWatch(context.Background(), issueWatch); err != nil { + t.Fatal(err) + } + prWatch := &PRWatch{WorkspaceID: "victim-workspace", SessionID: "session-1", TaskID: "task-1", Owner: "acme", Repo: "private", Branch: "main"} + if err := store.CreatePRWatch(context.Background(), prWatch); err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec(`INSERT INTO tasks (id, workspace_id) VALUES (?, ?)`, prWatch.TaskID, prWatch.WorkspaceID); err != nil { + t.Fatal(err) + } + svc := NewService(nil, AuthMethodNone, nil, store, nil, newControllerTestLogger()) + router := gin.New() + NewController(svc, newControllerTestLogger()).RegisterHTTPRoutes(router) + + for _, path := range []string{"/api/v1/github/watches/issue", "/api/v1/github/watches/pr"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + req = req.WithContext(authn.WithIdentity(req.Context(), authn.Identity{UserID: "foreign", Role: authn.RoleMember})) + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + if response.Code != http.StatusBadRequest || strings.Contains(response.Body.String(), "secret issue prompt") || strings.Contains(response.Body.String(), "private") { + t.Fatalf("GET %s = %d %s, want no unscoped disclosure", path, response.Code, response.Body.String()) + } + } + + for _, tc := range []struct { + path string + secret string + }{ + {"/api/v1/github/watches/issue?workspace_id=victim-workspace", "secret issue prompt"}, + {"/api/v1/github/watches/pr?workspace_id=victim-workspace", "private"}, + } { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + req = req.WithContext(authn.WithIdentity(req.Context(), authn.Identity{UserID: "workspace-member", Role: authn.RoleMember})) + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), tc.secret) { + t.Fatalf("GET %s = %d %s, want scoped watch", tc.path, response.Code, response.Body.String()) + } + } +} + +func TestWatchHTTPAndWSMutationsHideForeignRows(t *testing.T) { + store := newTestStore(t) + issueWatch := &IssueWatch{WorkspaceID: "victim-workspace"} + if err := store.CreateIssueWatch(context.Background(), issueWatch); err != nil { + t.Fatal(err) + } + prWatch := &PRWatch{WorkspaceID: "victim-workspace", SessionID: "session-1", TaskID: "task-1", Owner: "acme", Repo: "private", Branch: "main"} + if err := store.CreatePRWatch(context.Background(), prWatch); err != nil { + t.Fatal(err) + } + svc := NewService(nil, AuthMethodNone, nil, store, nil, newControllerTestLogger()) + svc.SetWorkspaceAuthorizer(func(context.Context, string) error { return repoerrors.ErrWorkspaceNotFound }) + router := gin.New() + NewController(svc, newControllerTestLogger()).RegisterHTTPRoutes(router) + + for _, path := range []string{ + "/api/v1/github/watches/issue/" + issueWatch.ID + "?workspace_id=victim-workspace", + "/api/v1/github/watches/pr/" + prWatch.ID + "?workspace_id=victim-workspace", + } { + req := httptest.NewRequest(http.MethodDelete, path, nil) + req = req.WithContext(authn.WithIdentity(req.Context(), authn.Identity{UserID: "foreign", Role: authn.RoleMember})) + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + if response.Code != http.StatusNotFound || strings.Contains(response.Body.String(), "workspace") { + t.Fatalf("DELETE %s = %d %s, want sanitized 404", path, response.Code, response.Body.String()) + } + } + + ctx := authn.WithIdentity(context.Background(), authn.Identity{UserID: "foreign", Role: authn.RoleMember}) + for _, tc := range []struct { + name string + handler func(context.Context, *ws.Message) (*ws.Message, error) + payload map[string]any + }{ + {"issue-list", wsListIssueWatches(svc, newControllerTestLogger()), map[string]any{"workspace_id": "victim-workspace"}}, + {"issue-delete", wsDeleteIssueWatch(svc, newControllerTestLogger()), map[string]any{"id": issueWatch.ID}}, + {"pr-list", wsListPRWatches(svc, newControllerTestLogger()), map[string]any{"workspace_id": "victim-workspace"}}, + {"pr-delete", wsDeletePRWatch(svc, newControllerTestLogger()), map[string]any{"id": prWatch.ID}}, + } { + t.Run(tc.name, func(t *testing.T) { + message, err := ws.NewRequest("request-1", "github.watch", tc.payload) + if err != nil { + t.Fatal(err) + } + response, err := tc.handler(ctx, message) + if err != nil { + t.Fatal(err) + } + if response.Type != ws.MessageTypeError || !strings.Contains(string(response.Payload), ws.ErrorCodeNotFound) { + t.Fatalf("response = %#v, want sanitized NOT_FOUND", response) + } + }) + } +} + func waitForPublishedCount(t *testing.T, eb *mockEventBus, want int) { t.Helper() timeout := time.After(time.Second) @@ -447,6 +715,7 @@ func TestResetReviewWatchPublishesNewPREvents(t *testing.T) { log := newControllerTestLogger() eb := &mockEventBus{publishedCh: make(chan struct{}, 1)} svc := NewService(client, "pat", nil, store, eb, log) + configureControllerTestResolver(svc, client, "ws-1") svc.SetCascadeTaskDeleter(noopCascadeTaskDeleter{}) deleted, err := svc.ResetReviewWatch(context.Background(), watch.ID) @@ -514,6 +783,7 @@ func TestHttpResetReviewWatchPublishesNewPREvents(t *testing.T) { log := newControllerTestLogger() eb := &mockEventBus{publishedCh: make(chan struct{}, 1)} svc := NewService(client, "pat", nil, store, eb, log) + configureControllerTestResolver(svc, client, "ws-1") svc.SetCascadeTaskDeleter(noopCascadeTaskDeleter{}) router := gin.New() NewController(svc, log).RegisterHTTPRoutes(router) @@ -550,7 +820,7 @@ func TestHttpLinkTaskIssue_SuccessAndInvalidJSON(t *testing.T) { }, }) ctrl.service.SetTaskIssueStore(&fakeTaskIssueStore{ - task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{"keep": "me"}}, + task: &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1", Metadata: map[string]interface{}{"keep": "me"}}, repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}}, entities: map[string]*taskmodels.Repository{ "repo-1": {ID: "repo-1", Provider: "github", ProviderOwner: "kdlbs", ProviderName: "kandev"}, @@ -639,7 +909,7 @@ func TestHttpLinkTaskIssue_ErrorMapping(t *testing.T) { { name: "no client", client: nil, - store: &fakeTaskIssueStore{}, + store: &fakeTaskIssueStore{task: &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"}}, body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`, wantStatus: http.StatusServiceUnavailable, wantError: "GitHub is not configured. Connect GitHub in Settings > Integrations.", @@ -656,7 +926,7 @@ func TestHttpLinkTaskIssue_ErrorMapping(t *testing.T) { { name: "invalid reference", client: &stubClient{}, - store: &fakeTaskIssueStore{}, + store: &fakeTaskIssueStore{task: &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"}}, body: `{"issue":"#1470"}`, wantStatus: http.StatusBadRequest, wantError: "invalid GitHub issue reference: owner and repo are required for issue numbers", @@ -667,7 +937,7 @@ func TestHttpLinkTaskIssue_ErrorMapping(t *testing.T) { return &Issue{Number: 1, HTMLURL: "https://github.com/other/repo/issues/1", RepoOwner: "other", RepoName: "repo"}, nil }}, store: &fakeTaskIssueStore{ - task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}, + task: &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1", Metadata: map[string]interface{}{}}, repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}}, entities: map[string]*taskmodels.Repository{ "repo-1": {ID: "repo-1", Provider: "github", ProviderOwner: "kdlbs", ProviderName: "kandev"}, @@ -682,7 +952,7 @@ func TestHttpLinkTaskIssue_ErrorMapping(t *testing.T) { client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) { return nil, &GitHubAPIError{StatusCode: http.StatusNotFound, Endpoint: "/repos/kdlbs/kandev/issues/1470", Body: "private detail"} }}, - store: &fakeTaskIssueStore{task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}}, + store: &fakeTaskIssueStore{task: &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1", Metadata: map[string]interface{}{}}}, body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`, wantStatus: http.StatusNotFound, wantError: "failed to fetch GitHub issue", @@ -692,7 +962,7 @@ func TestHttpLinkTaskIssue_ErrorMapping(t *testing.T) { client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) { return nil, &GitHubAPIError{StatusCode: http.StatusUnauthorized, Endpoint: "/repos/kdlbs/kandev/issues/1470", Body: "private detail"} }}, - store: &fakeTaskIssueStore{task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}}, + store: &fakeTaskIssueStore{task: &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1", Metadata: map[string]interface{}{}}}, body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`, wantStatus: http.StatusUnauthorized, wantError: "failed to fetch GitHub issue", @@ -702,7 +972,7 @@ func TestHttpLinkTaskIssue_ErrorMapping(t *testing.T) { client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) { return nil, &GitHubAPIError{StatusCode: http.StatusForbidden, Endpoint: "/repos/kdlbs/kandev/issues/1470", Body: "private detail"} }}, - store: &fakeTaskIssueStore{task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}}, + store: &fakeTaskIssueStore{task: &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1", Metadata: map[string]interface{}{}}}, body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`, wantStatus: http.StatusForbidden, wantError: "failed to fetch GitHub issue", @@ -723,7 +993,7 @@ func TestHttpLinkTaskIssue_ErrorMapping(t *testing.T) { return &Issue{Number: 1470, HTMLURL: "https://github.com/kdlbs/kandev/issues/1470", RepoOwner: "kdlbs", RepoName: "kandev"}, nil }}, store: &fakeTaskIssueStore{ - task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}, + task: &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1", Metadata: map[string]interface{}{}}, updateErr: errors.New("database unavailable"), }, body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`, @@ -979,6 +1249,51 @@ func TestHttpGetPRInfo_NoClient(t *testing.T) { } } +func TestHttpGetPRStatusesBatchAuthorizesBodyWorkspace(t *testing.T) { + router, controller := setupControllerTest(&stubClient{}) + denied := errors.New("workspace not found") + controller.service.SetWorkspaceAuthorizer(func(_ context.Context, workspaceID string) error { + if workspaceID != "victim-workspace" { + t.Fatalf("workspaceID = %q, want victim workspace", workspaceID) + } + return denied + }) + body := bytes.NewBufferString(`{"workspace_id":"victim-workspace","refs":[]}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/github/prs/statuses", body) + req = req.WithContext(authn.WithIdentity(req.Context(), authn.Identity{UserID: "attacker", Role: authn.RoleMember})) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError || !strings.Contains(w.Body.String(), denied.Error()) { + t.Fatalf("response = %d %s, want denied body-workspace access", w.Code, w.Body.String()) + } +} + +func TestWSGetPRFeedbackAuthorizesPayloadWorkspace(t *testing.T) { + service := NewService(&stubClient{}, AuthMethodPAT, nil, nil, nil, newControllerTestLogger()) + denied := errors.New("workspace not found") + service.SetWorkspaceAuthorizer(func(_ context.Context, workspaceID string) error { + if workspaceID != "victim-workspace" { + t.Fatalf("workspaceID = %q, want victim workspace", workspaceID) + } + return denied + }) + message, err := ws.NewRequest("request-1", "github.pr_feedback.get", map[string]any{ + "workspace_id": "victim-workspace", "owner": "acme", "repo": "widget", "number": 1, + }) + if err != nil { + t.Fatal(err) + } + ctx := authn.WithIdentity(context.Background(), authn.Identity{UserID: "attacker", Role: authn.RoleMember}) + response, err := wsGetPRFeedback(service, newControllerTestLogger())(ctx, message) + if err != nil { + t.Fatalf("handler error = %v", err) + } + if response.Type != ws.MessageTypeError || !strings.Contains(string(response.Payload), denied.Error()) { + t.Fatalf("response = %#v, want denied workspace access", response) + } +} + func TestHttpGetPRInfo_GitHubAPIErrorStatus(t *testing.T) { sc := &stubClient{ getPRFunc: func(context.Context, string, string, int) (*PR, error) { @@ -1240,7 +1555,7 @@ func TestHttpGetRepoMergeMethods_OK(t *testing.T) { } router, _ := setupControllerTest(sc) - req := httptest.NewRequest(http.MethodGet, "/api/v1/github/repos/acme/widget/merge-methods", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/github/repos/acme/widget/merge-methods?workspace_id=ws-1", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -1266,7 +1581,7 @@ func TestHttpGetRepoMergeMethods_NoClient_Returns503(t *testing.T) { router := gin.New() ctrl.RegisterHTTPRoutes(router) - req := httptest.NewRequest(http.MethodGet, "/api/v1/github/repos/acme/widget/merge-methods", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/github/repos/acme/widget/merge-methods?workspace_id=ws-1", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -1335,7 +1650,7 @@ func TestHttpMergePR_NoClient_Returns503(t *testing.T) { router := gin.New() ctrl.RegisterHTTPRoutes(router) - req := httptest.NewRequest(http.MethodPut, "/api/v1/github/prs/acme/widget/42/merge", nil) + req := httptest.NewRequest(http.MethodPut, "/api/v1/github/prs/acme/widget/42/merge?workspace_id=ws-1", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -1389,6 +1704,7 @@ func TestHttpSubmitReview_SelfApproveReturns422(t *testing.T) { func TestHttpRequestReviewers_RequestsReviewers(t *testing.T) { client := NewMockClient() + client.AddRepos("test-user", []GitHubRepo{{Owner: "acme", Name: "widget", FullName: "acme/widget"}}) router, _ := setupControllerTest(client) req := httptest.NewRequest(http.MethodPost, "/api/v1/github/prs/acme/widget/42/requested-reviewers", strings.NewReader(`{"reviewers":["octocat"]}`)) @@ -1400,7 +1716,8 @@ func TestHttpRequestReviewers_RequestsReviewers(t *testing.T) { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var response struct { - Requested bool `json:"requested"` + Requested bool `json:"requested"` + Principal AuthPrincipal `json:"principal"` } if err := json.NewDecoder(w.Body).Decode(&response); err != nil { t.Fatalf("decode response: %v", err) @@ -1408,10 +1725,14 @@ func TestHttpRequestReviewers_RequestsReviewers(t *testing.T) { if !response.Requested { t.Fatal("expected requested=true") } + if response.Principal.WorkspaceID != "ws-1" { + t.Fatalf("request principal workspace = %q, want ws-1", response.Principal.WorkspaceID) + } } func TestHttpRequestReviewers_NormalizesReviewersBeforeClientDelegation(t *testing.T) { client := NewMockClient() + client.AddRepos("test-user", []GitHubRepo{{Owner: "acme", Name: "widget", FullName: "acme/widget"}}) router, _ := setupControllerTest(client) req := httptest.NewRequest(http.MethodPost, "/api/v1/github/prs/acme/widget/42/requested-reviewers", strings.NewReader(`{"reviewers":[" OctoCat ","octocat","hubot ","HUBOT"]}`)) @@ -1499,6 +1820,35 @@ func TestHttpRequestReviewers_ErrNoClientReturns503(t *testing.T) { } } +func TestHttpRequestReviewers_RepositoryOutsideWorkspaceReturnsSanitizedNotFound(t *testing.T) { + router, _ := setupControllerTest(NewMockClient()) + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/github/prs/acme/widget/42/requested-reviewers", + strings.NewReader(`{"reviewers":["octocat"]}`), + ) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } + var response struct { + Code string `json:"code"` + Error string `json:"error"` + } + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Code != "github_repository_not_found" { + t.Fatalf("code = %q, want github_repository_not_found", response.Code) + } + if response.Error != "GitHub repository is not available in this workspace." { + t.Fatalf("error = %q", response.Error) + } +} + func TestHttpRequestReviewers_UpstreamErrorReturns500(t *testing.T) { router, _ := setupControllerTest(&stubClient{ requestReviewersFn: func(context.Context, string, string, int, []string) error { @@ -1585,7 +1935,7 @@ func TestHandleListAccessibleRepos_503_WhenUnavailable(t *testing.T) { router := gin.New() ctrl.RegisterHTTPRoutes(router) - req := httptest.NewRequest(http.MethodGet, "/api/v1/github/repos", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/github/repos?workspace_id=ws-1", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) diff --git a/apps/backend/internal/github/controller_watch_reset.go b/apps/backend/internal/github/controller_watch_reset.go index 4fac5fdc2c..81a8ea6f6b 100644 --- a/apps/backend/internal/github/controller_watch_reset.go +++ b/apps/backend/internal/github/controller_watch_reset.go @@ -1,10 +1,13 @@ package github import ( + "errors" "net/http" "github.com/gin-gonic/gin" "go.uber.org/zap" + + "github.com/kandev/kandev/internal/task/repository/repoerrors" ) // httpPreviewResetReviewWatch returns the count of tasks a reset on the @@ -76,6 +79,10 @@ func (c *Controller) httpResetIssueWatch(ctx *gin.Context) { func (c *Controller) requireReviewWatchInWorkspace(ctx *gin.Context, id string) bool { watch, err := c.service.GetReviewWatch(ctx.Request.Context(), id) if err != nil { + if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + ctx.JSON(http.StatusNotFound, gin.H{"error": "review watch not found"}) + return false + } c.writeResetError(ctx, "load review watch", err) return false } @@ -91,6 +98,10 @@ func (c *Controller) requireReviewWatchInWorkspace(ctx *gin.Context, id string) func (c *Controller) requireIssueWatchInWorkspace(ctx *gin.Context, id string) bool { watch, err := c.service.GetIssueWatch(ctx.Request.Context(), id) if err != nil { + if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + ctx.JSON(http.StatusNotFound, gin.H{"error": "issue watch not found"}) + return false + } c.writeResetError(ctx, "load issue watch", err) return false } diff --git a/apps/backend/internal/github/controller_webhook_test.go b/apps/backend/internal/github/controller_webhook_test.go new file mode 100644 index 0000000000..f9f213077c --- /dev/null +++ b/apps/backend/internal/github/controller_webhook_test.go @@ -0,0 +1,70 @@ +package github + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestHTTPGitHubWebhookRoutesByAppRegistration(t *testing.T) { + gin.SetMode(gin.TestMode) + store := &githubWebhookMemoryStore{} + webhooks := NewAppRegistrationWebhookService( + "registration-work", "work-secret", store, nil, nil, + ) + runtime := &githubAppRuntime{ + registrationID: "registration-work", source: DeploymentAppSourceManaged, + generation: 1, webhookAuth: webhooks, + } + service := &Service{appRegistrationRuntimes: map[string]*githubAppRuntime{ + "registration-work": runtime, + }} + router := gin.New() + NewController(service, nil).RegisterHTTPRoutes(router) + + payload := `{"action":"created","installation":{"id":42}}` + request := signedWebhookRequest( + "work-secret", "delivery-1", "installation", []byte(payload), + ) + response := performWebhookRequest( + router, "/api/v1/github/app/registrations/registration-work/webhook", request, + ) + if response.Code != http.StatusOK { + t.Fatalf("matching route status = %d, body = %s", response.Code, response.Body.String()) + } + if len(store.deliveries) != 1 { + t.Fatalf("matching route deliveries = %#v", store.deliveries) + } + + response = performWebhookRequest( + router, "/api/v1/github/app/registrations/registration-personal/webhook", request, + ) + if response.Code != http.StatusConflict || len(store.deliveries) != 1 { + t.Fatalf( + "wrong route status = %d, deliveries = %#v, body = %s", + response.Code, store.deliveries, response.Body.String(), + ) + } + + response = performWebhookRequest(router, "/api/v1/github/app/webhook", request) + if response.Code != http.StatusNotFound { + t.Fatalf("legacy route status = %d, want 404", response.Code) + } +} + +func performWebhookRequest( + router http.Handler, + path string, + request GitHubWebhookRequest, +) *httptest.ResponseRecorder { + httpRequest := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(request.Payload))) + httpRequest.Header.Set("X-GitHub-Delivery", request.DeliveryID) + httpRequest.Header.Set("X-GitHub-Event", request.Event) + httpRequest.Header.Set("X-Hub-Signature-256", request.Signature) + response := httptest.NewRecorder() + router.ServeHTTP(response, httpRequest) + return response +} diff --git a/apps/backend/internal/github/copy.go b/apps/backend/internal/github/copy.go index c4d3504951..be180a2440 100644 --- a/apps/backend/internal/github/copy.go +++ b/apps/backend/internal/github/copy.go @@ -13,9 +13,9 @@ var ErrSameWorkspace = errors.New("github: source and target workspaces are the // CopyWorkspaceSettingsToWorkspace copies the per-workspace GitHub operational // settings (repo scope + saved/default query presets) and the workspace's -// quick-action presets from sourceWorkspaceID to targetWorkspaceID. GitHub -// authentication is install-wide, so there are no credentials to copy — only the -// workspace-scoped settings. Watchers are intentionally out of scope. +// quick-action presets from sourceWorkspaceID to targetWorkspaceID. Workspace +// automation and personal authentication are intentionally excluded. Watchers +// are also out of scope. func (s *Service) CopyWorkspaceSettingsToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) (*WorkspaceSettings, error) { sourceWorkspaceID = strings.TrimSpace(sourceWorkspaceID) targetWorkspaceID = strings.TrimSpace(targetWorkspaceID) @@ -25,6 +25,12 @@ func (s *Service) CopyWorkspaceSettingsToWorkspace(ctx context.Context, sourceWo if sourceWorkspaceID == targetWorkspaceID { return nil, ErrSameWorkspace } + if err := s.authorizeWorkspaceAccess(ctx, sourceWorkspaceID); err != nil { + return nil, err + } + if err := s.authorizeWorkspaceAccess(ctx, targetWorkspaceID); err != nil { + return nil, err + } if s.store == nil { return nil, fmt.Errorf("github store not configured") } diff --git a/apps/backend/internal/github/copy_test.go b/apps/backend/internal/github/copy_test.go index 23b2c6f2a6..72db40f89b 100644 --- a/apps/backend/internal/github/copy_test.go +++ b/apps/backend/internal/github/copy_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" ) func newCopyTestService(t *testing.T) *Service { @@ -61,6 +62,49 @@ func TestCopyWorkspaceSettingsToWorkspace_CopiesSettings(t *testing.T) { } } +func TestCopyWorkspaceSettingsToWorkspace_DoesNotCopyAuthentication(t *testing.T) { + svc := newCopyTestService(t) + ctx := context.Background() + const src, dst = "ws-src", "ws-dst" + seedConnectionWorkspaces(t, svc.store, src, dst) + seedStoreAppRegistration(t, svc.store) + now := time.Now().UTC() + installationID := int64(42) + if err := svc.store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ + WorkspaceID: src, Source: ConnectionSourceGitHubAppInstallation, GitHubHost: "github.com", + InstallationID: &installationID, InstallationAccountLogin: "acme", + InstallationAccountType: "Organization", AppRegistrationID: "registration-store-test", + Status: ConnectionStatusActive, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed source connection: %v", err) + } + if err := svc.store.UpsertUserConnection(ctx, &UserConnection{ + WorkspaceID: src, UserID: "default-user", AppRegistrationID: "registration-store-test", + GitHubUserID: 42, Login: "octocat", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed source user connection: %v", err) + } + + if _, err := svc.CopyWorkspaceSettingsToWorkspace(ctx, src, dst); err != nil { + t.Fatalf("copy: %v", err) + } + connection, err := svc.store.GetWorkspaceConnection(ctx, dst) + if err != nil { + t.Fatalf("get target connection: %v", err) + } + if connection != nil { + t.Fatalf("target inherited workspace auth: %+v", connection) + } + user, err := svc.store.GetUserConnection(ctx, dst, "default-user") + if err != nil { + t.Fatalf("get target user connection: %v", err) + } + if user != nil { + t.Fatalf("target inherited personal auth: %+v", user) + } +} + // When the source relies on default presets (no stored row), the copy must // clear the target's customised presets so both fall back to the same defaults — // otherwise the copy silently leaves the target diverged from the source. diff --git a/apps/backend/internal/github/credential_broker.go b/apps/backend/internal/github/credential_broker.go new file mode 100644 index 0000000000..b1815450e2 --- /dev/null +++ b/apps/backend/internal/github/credential_broker.go @@ -0,0 +1,411 @@ +package github + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +const ( + defaultCredentialLeaseTTL = 12 * time.Hour + credentialLeaseBytes = 32 + maxCredentialLeasesPerWorkspace = 10_000 + gitHubTokenUsername = "x-access-token" +) + +var ( + ErrCredentialLeaseInvalid = errors.New("GitHub credential lease is invalid") + ErrCredentialLeaseExpired = errors.New("GitHub credential lease is expired") + ErrCredentialLeaseRevoked = errors.New("GitHub credential lease was revoked") + ErrCredentialLeaseLimit = errors.New("GitHub credential lease limit reached") + ErrCredentialScopeDenied = errors.New("GitHub credential scope denied") +) + +// BrokerScopeAuthorizer verifies task/workspace/repository ownership. It is +// called both when a lease is issued and each time it is redeemed. +type BrokerScopeAuthorizer interface { + AuthorizeGitHubRepository( + ctx context.Context, + workspaceID, taskID, sessionID, repositoryID, owner, repo string, + ) error +} + +type CredentialLeaseRequest struct { + WorkspaceID string + TaskID string + SessionID string + RepositoryID string + Owner string + Repo string + Host string + TTL time.Duration +} + +type CredentialLease struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` +} + +type BrokerCredentialRequest struct { + Lease string + TaskID string + SessionID string + RepositoryID string + Owner string + Repo string + Host string +} + +type BrokerCredential struct { + Username string `json:"username"` + Password string `json:"password"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Principal AuthPrincipal `json:"principal"` +} + +type credentialLeaseRecord struct { + WorkspaceID string + TaskID string + SessionID string + RepositoryID string + Owner string + Repo string + Host string + CredentialGeneration int64 + AppRegistrationID string + AppCredentialGeneration int64 + TTL time.Duration + ExpiresAt time.Time +} + +// CredentialBroker exchanges opaque, task-scoped leases for renewable +// automation credentials. Only lease hashes and non-secret scope metadata are +// retained; raw leases and GitHub tokens are never persisted in the broker. +type CredentialBroker struct { + connections workspaceConnectionReader + resolver *CredentialResolver + authorizer BrokerScopeAuthorizer + + mu sync.Mutex + leases map[[sha256.Size]byte]credentialLeaseRecord + now func() time.Time +} + +func NewCredentialBroker( + connections workspaceConnectionReader, + resolver *CredentialResolver, + authorizer BrokerScopeAuthorizer, +) *CredentialBroker { + return &CredentialBroker{ + connections: connections, + resolver: resolver, + authorizer: authorizer, + leases: make(map[[sha256.Size]byte]credentialLeaseRecord), + now: time.Now, + } +} + +func (b *CredentialBroker) Issue(ctx context.Context, req CredentialLeaseRequest) (*CredentialLease, error) { + if err := validateCredentialLeaseScope( + req.WorkspaceID, req.TaskID, req.SessionID, req.RepositoryID, req.Owner, req.Repo, req.Host, + ); err != nil { + return nil, err + } + if b == nil || b.connections == nil || b.resolver == nil || b.authorizer == nil { + return nil, ErrGitHubNotConfigured + } + if err := b.authorizer.AuthorizeGitHubRepository( + ctx, req.WorkspaceID, req.TaskID, req.SessionID, req.RepositoryID, req.Owner, req.Repo, + ); err != nil { + return nil, fmt.Errorf("%w: %v", ErrCredentialScopeDenied, err) + } + connection, appCredentialGeneration, err := b.issueConnection(ctx, req.WorkspaceID) + if err != nil { + return nil, err + } + ttl := req.TTL + if ttl <= 0 || ttl > defaultCredentialLeaseTTL { + ttl = defaultCredentialLeaseTTL + } + raw := make([]byte, credentialLeaseBytes) + if _, err := rand.Read(raw); err != nil { + return nil, fmt.Errorf("create GitHub credential lease: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(raw) + hash := sha256.Sum256([]byte(token)) + now := b.now() + expiresAt := now.UTC().Add(ttl) + + b.mu.Lock() + defer b.mu.Unlock() + b.sweepExpiredLocked(now) + if len(b.leases) >= maxCredentialLeasesPerWorkspace && + b.workspaceLeaseCountLocked(req.WorkspaceID) >= maxCredentialLeasesPerWorkspace { + return nil, fmt.Errorf("%w for workspace %s", ErrCredentialLeaseLimit, req.WorkspaceID) + } + b.leases[hash] = credentialLeaseRecord{ + WorkspaceID: req.WorkspaceID, + TaskID: req.TaskID, + SessionID: req.SessionID, + RepositoryID: req.RepositoryID, + Owner: strings.ToLower(req.Owner), + Repo: strings.ToLower(req.Repo), + Host: strings.ToLower(req.Host), + CredentialGeneration: connection.CredentialGeneration, + AppRegistrationID: connection.AppRegistrationID, + AppCredentialGeneration: appCredentialGeneration, + TTL: ttl, + ExpiresAt: expiresAt, + } + return &CredentialLease{Token: token, ExpiresAt: expiresAt}, nil +} + +func (b *CredentialBroker) issueConnection( + ctx context.Context, + workspaceID string, +) (*WorkspaceConnection, int64, error) { + connection, err := b.connections.GetWorkspaceConnection(ctx, workspaceID) + if err != nil { + return nil, 0, fmt.Errorf("load GitHub workspace connection: %w", err) + } + if connection == nil { + return nil, 0, ErrGitHubNotConfigured + } + if connection.Status != ConnectionStatusActive { + return nil, 0, ErrGitHubConnectionInvalid + } + if connection.Source != ConnectionSourceGitHubAppInstallation { + return connection, 0, nil + } + if strings.TrimSpace(connection.AppRegistrationID) == "" { + return nil, 0, ErrGitHubNotConfigured + } + generation, err := b.resolver.appCredentialGeneration(connection.AppRegistrationID) + if err != nil { + return nil, 0, err + } + return connection, generation, nil +} + +func (b *CredentialBroker) Resolve( + ctx context.Context, + req BrokerCredentialRequest, +) (*BrokerCredential, error) { + if b == nil || b.connections == nil || b.resolver == nil || b.authorizer == nil { + return nil, ErrGitHubNotConfigured + } + if strings.TrimSpace(req.Lease) == "" { + return nil, ErrCredentialLeaseInvalid + } + record, err := b.loadLease(req) + if err != nil { + return nil, err + } + if err := b.authorizeLease(ctx, record); err != nil { + return nil, err + } + credential, err := b.resolveLeaseCredential(ctx, record) + if err != nil { + return nil, err + } + if err := b.validateLeaseConnection(ctx, record); err != nil { + return nil, err + } + if !b.renewLease(req.Lease) { + return nil, ErrCredentialLeaseRevoked + } + return credential, nil +} + +func (b *CredentialBroker) loadLease(req BrokerCredentialRequest) (credentialLeaseRecord, error) { + hash := sha256.Sum256([]byte(req.Lease)) + now := b.now() + b.mu.Lock() + record, ok := b.leases[hash] + b.sweepExpiredLocked(now) + b.mu.Unlock() + if !ok { + return credentialLeaseRecord{}, ErrCredentialLeaseInvalid + } + if !record.ExpiresAt.After(now) { + return credentialLeaseRecord{}, ErrCredentialLeaseExpired + } + if !credentialLeaseMatches(record, req) { + return credentialLeaseRecord{}, ErrCredentialScopeDenied + } + return record, nil +} + +func (b *CredentialBroker) authorizeLease(ctx context.Context, record credentialLeaseRecord) error { + if err := b.authorizer.AuthorizeGitHubRepository( + ctx, record.WorkspaceID, record.TaskID, record.SessionID, + record.RepositoryID, record.Owner, record.Repo, + ); err != nil { + return fmt.Errorf("%w: %v", ErrCredentialScopeDenied, err) + } + return b.validateLeaseConnection(ctx, record) +} + +func (b *CredentialBroker) validateLeaseConnection( + ctx context.Context, + record credentialLeaseRecord, +) error { + connection, err := b.connections.GetWorkspaceConnection(ctx, record.WorkspaceID) + if err != nil { + return fmt.Errorf("load GitHub workspace connection: %w", err) + } + if connection == nil || connection.Status != ConnectionStatusActive || + connection.CredentialGeneration != record.CredentialGeneration || + connection.AppRegistrationID != record.AppRegistrationID { + return ErrCredentialLeaseRevoked + } + if record.AppRegistrationID != "" { + generation, generationErr := b.resolver.appCredentialGeneration(record.AppRegistrationID) + if generationErr != nil || generation != record.AppCredentialGeneration { + return ErrCredentialLeaseRevoked + } + } + return nil +} + +func (b *CredentialBroker) resolveLeaseCredential( + ctx context.Context, + record credentialLeaseRecord, +) (*BrokerCredential, error) { + resolved, err := b.resolver.Resolve(ctx, ResolveCredentialRequest{ + WorkspaceID: record.WorkspaceID, + Purpose: CredentialPurposeGitTransport, + RepoOwner: record.Owner, + RepoName: record.Repo, + }) + if err != nil { + return nil, err + } + if resolved.CredentialGeneration != record.CredentialGeneration || + resolved.AppRegistrationID != record.AppRegistrationID || + resolved.AppCredentialGeneration != record.AppCredentialGeneration || resolved.credential == "" { + return nil, ErrCredentialLeaseRevoked + } + // Credential-helper get requests do not distinguish fetch from push. Read + // is the minimum transport capability; GitHub enforces write permission if + // the returned installation token is subsequently used for a push. + if resolved.Principal.Kind == AuthPrincipalApp && !resolved.Capabilities[CapabilityGitRead] { + return nil, fmt.Errorf("%w: %s", ErrGitHubCapabilityDenied, CapabilityGitRead) + } + return &BrokerCredential{ + Username: gitHubTokenUsername, + Password: resolved.credential, + ExpiresAt: resolved.ExpiresAt, + Principal: resolved.Principal, + }, nil +} + +func (b *CredentialBroker) renewLease(rawLease string) bool { + hash := sha256.Sum256([]byte(rawLease)) + now := b.now() + b.mu.Lock() + defer b.mu.Unlock() + record, ok := b.leases[hash] + if !ok || !record.ExpiresAt.After(now) { + return false + } + ttl := record.TTL + if ttl <= 0 || ttl > defaultCredentialLeaseTTL { + ttl = defaultCredentialLeaseTTL + } + record.ExpiresAt = now.UTC().Add(ttl) + b.leases[hash] = record + return true +} + +func (b *CredentialBroker) sweepExpiredLocked(now time.Time) { + for hash, record := range b.leases { + if !record.ExpiresAt.After(now) { + delete(b.leases, hash) + } + } +} + +func (b *CredentialBroker) workspaceLeaseCountLocked(workspaceID string) int { + count := 0 + for _, record := range b.leases { + if record.WorkspaceID == workspaceID { + count++ + } + } + return count +} + +func (b *CredentialBroker) RevokeTask(taskID string) { + if b == nil { + return + } + b.mu.Lock() + defer b.mu.Unlock() + for hash, record := range b.leases { + if record.TaskID == taskID { + delete(b.leases, hash) + } + } +} + +func (b *CredentialBroker) RevokeSession(sessionID string) { + if b == nil { + return + } + b.mu.Lock() + defer b.mu.Unlock() + for hash, record := range b.leases { + if record.SessionID == sessionID { + delete(b.leases, hash) + } + } +} + +func (b *CredentialBroker) RevokeWorkspace(workspaceID string) { + if b == nil { + return + } + b.mu.Lock() + defer b.mu.Unlock() + for hash, record := range b.leases { + if record.WorkspaceID == workspaceID { + delete(b.leases, hash) + } + } +} + +func validateCredentialLeaseScope(workspaceID, taskID, sessionID, repositoryID, owner, repo, host string) error { + for name, value := range map[string]string{ + "workspace": workspaceID, + "task": taskID, + "session": sessionID, + "repository": repositoryID, + "owner": owner, + "repo": repo, + "host": host, + } { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%w: %s is required", ErrCredentialLeaseInvalid, name) + } + } + if !strings.EqualFold(host, defaultGitHubHost) { + return fmt.Errorf("%w: unsupported host", ErrCredentialScopeDenied) + } + return nil +} + +func credentialLeaseMatches(record credentialLeaseRecord, req BrokerCredentialRequest) bool { + return record.TaskID == req.TaskID && + record.SessionID == req.SessionID && + record.RepositoryID == req.RepositoryID && + strings.EqualFold(record.Owner, req.Owner) && + strings.EqualFold(record.Repo, req.Repo) && + strings.EqualFold(record.Host, req.Host) +} diff --git a/apps/backend/internal/github/credential_broker_test.go b/apps/backend/internal/github/credential_broker_test.go new file mode 100644 index 0000000000..7a3f94890b --- /dev/null +++ b/apps/backend/internal/github/credential_broker_test.go @@ -0,0 +1,447 @@ +package github + +import ( + "context" + "errors" + "fmt" + "testing" + "time" +) + +type fakeBrokerAuthorizer struct { + err error + calls int + sessionIDs []string +} + +type fakeBrokerInstallationProvider struct { + resolved *ResolvedCredential +} + +type blockingBrokerInstallationProvider struct { + started chan struct{} + release chan struct{} + resolved *ResolvedCredential +} + +func (p *blockingBrokerInstallationProvider) AppCredentialGeneration( + registrationID string, +) (int64, bool) { + return p.resolved.AppCredentialGeneration, + p.resolved.AppRegistrationID == registrationID && p.resolved.AppCredentialGeneration > 0 +} + +func (p *blockingBrokerInstallationProvider) ResolveInstallation( + ctx context.Context, + _ *WorkspaceConnection, + _ ResolveCredentialRequest, +) (*ResolvedCredential, error) { + select { + case p.started <- struct{}{}: + case <-ctx.Done(): + return nil, ctx.Err() + } + select { + case <-p.release: + return p.resolved, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (f fakeBrokerInstallationProvider) AppCredentialGeneration(registrationID string) (int64, bool) { + return f.resolved.AppCredentialGeneration, + f.resolved.AppRegistrationID == registrationID && f.resolved.AppCredentialGeneration > 0 +} + +func (f fakeBrokerInstallationProvider) ResolveInstallation( + _ context.Context, + _ *WorkspaceConnection, + _ ResolveCredentialRequest, +) (*ResolvedCredential, error) { + return f.resolved, nil +} + +func (a *fakeBrokerAuthorizer) AuthorizeGitHubRepository( + _ context.Context, + _, _, sessionID, _, _, _ string, +) error { + a.calls++ + a.sessionIDs = append(a.sessionIDs, sessionID) + return a.err +} + +func newPATCredentialBroker(t *testing.T) (*CredentialBroker, *WorkspaceConnection, *fakeBrokerAuthorizer) { + t.Helper() + connection := &WorkspaceConnection{ + WorkspaceID: "workspace-1", + Source: ConnectionSourcePAT, + Login: "octocat", + Status: ConnectionStatusActive, + CredentialGeneration: 7, + } + connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + connection.WorkspaceID: connection, + }} + resolver := NewCredentialResolver(connections, fakeAuthSecrets{ + WorkspacePATSecretKey(connection.WorkspaceID): "workspace-secret", + }) + authorizer := &fakeBrokerAuthorizer{} + return NewCredentialBroker(connections, resolver, authorizer), connection, authorizer +} + +func brokerLeaseRequest() CredentialLeaseRequest { + return CredentialLeaseRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + SessionID: "session-1", + RepositoryID: "repository-1", + Owner: "kdlbs", + Repo: "kandev", + Host: "github.com", + } +} + +func brokerCredentialRequest(lease string) BrokerCredentialRequest { + return BrokerCredentialRequest{ + Lease: lease, TaskID: "task-1", SessionID: "session-1", + RepositoryID: "repository-1", Owner: "kdlbs", Repo: "kandev", Host: "github.com", + } +} + +func TestCredentialBrokerStoresHashAndRenewsCredentialOnRedemption(t *testing.T) { + broker, _, authorizer := newPATCredentialBroker(t) + lease, err := broker.Issue(context.Background(), brokerLeaseRequest()) + if err != nil { + t.Fatalf("Issue: %v", err) + } + if lease.Token == "" { + t.Fatal("empty lease") + } + for _, record := range broker.leases { + if record.TaskID != "task-1" { + t.Fatalf("record = %+v", record) + } + } + credential, err := broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if credential.Password != "workspace-secret" || credential.Username != "x-access-token" { + t.Fatalf("credential = %+v", credential) + } + if authorizer.calls != 2 { + t.Fatalf("scope checks = %d, want 2", authorizer.calls) + } + if got := authorizer.sessionIDs; len(got) != 2 || got[0] != "session-1" || got[1] != "session-1" { + t.Fatalf("authorized sessions = %v, want session-1 twice", got) + } +} + +func TestCredentialBrokerClampsRequestedLeaseTTL(t *testing.T) { + broker, _, _ := newPATCredentialBroker(t) + now := time.Now().UTC() + broker.now = func() time.Time { return now } + req := brokerLeaseRequest() + req.TTL = 30 * 24 * time.Hour + + lease, err := broker.Issue(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if want := now.Add(defaultCredentialLeaseTTL); !lease.ExpiresAt.Equal(want) { + t.Fatalf("ExpiresAt = %v, want capped expiry %v", lease.ExpiresAt, want) + } +} + +func TestCredentialBrokerRenewsLeaseOnSuccessfulRedemption(t *testing.T) { + broker, _, _ := newPATCredentialBroker(t) + issuedAt := time.Now().UTC() + now := issuedAt + broker.now = func() time.Time { return now } + req := brokerLeaseRequest() + req.TTL = time.Minute + + lease, err := broker.Issue(context.Background(), req) + if err != nil { + t.Fatal(err) + } + now = issuedAt.Add(30 * time.Second) + if _, err := broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token)); err != nil { + t.Fatalf("first Resolve: %v", err) + } + + // This is after the original expiry, but still inside the renewed window. + now = issuedAt.Add(75 * time.Second) + if _, err := broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token)); err != nil { + t.Fatalf("Resolve after original expiry: %v", err) + } +} + +func TestCredentialBrokerSweepsExpiredLeasesWhenIssuing(t *testing.T) { + broker, _, _ := newPATCredentialBroker(t) + issuedAt := time.Now().UTC() + now := issuedAt + broker.now = func() time.Time { return now } + req := brokerLeaseRequest() + req.TTL = time.Minute + + if _, err := broker.Issue(context.Background(), req); err != nil { + t.Fatal(err) + } + now = issuedAt.Add(2 * time.Minute) + req.TaskID = "task-2" + req.SessionID = "session-2" + if _, err := broker.Issue(context.Background(), req); err != nil { + t.Fatal(err) + } + + if got := len(broker.leases); got != 1 { + t.Fatalf("lease records = %d, want only the active lease", got) + } +} + +func TestCredentialBrokerLimitsActiveLeasesPerWorkspace(t *testing.T) { + broker, _, _ := newPATCredentialBroker(t) + const maxExpectedLeases = 10_000 + request := brokerLeaseRequest() + for i := range maxExpectedLeases { + request.TaskID = fmt.Sprintf("task-%d", i) + request.SessionID = fmt.Sprintf("session-%d", i) + if _, err := broker.Issue(context.Background(), request); err != nil { + t.Fatalf("Issue(%d): %v", i, err) + } + } + + request.TaskID = "task-over-limit" + request.SessionID = "session-over-limit" + if _, err := broker.Issue(context.Background(), request); err == nil { + t.Fatal("Issue() succeeded after the workspace lease limit") + } +} + +func TestCredentialBrokerRejectsScopeMismatch(t *testing.T) { + broker, _, _ := newPATCredentialBroker(t) + lease, err := broker.Issue(context.Background(), brokerLeaseRequest()) + if err != nil { + t.Fatal(err) + } + request := brokerCredentialRequest(lease.Token) + request.TaskID = "other-task" + _, err = broker.Resolve(context.Background(), request) + if !errors.Is(err, ErrCredentialScopeDenied) { + t.Fatalf("error = %v, want scope denied", err) + } +} + +func TestCredentialBrokerGenerationChangeRevokesLease(t *testing.T) { + broker, connection, _ := newPATCredentialBroker(t) + lease, err := broker.Issue(context.Background(), brokerLeaseRequest()) + if err != nil { + t.Fatal(err) + } + connection.CredentialGeneration++ + _, err = broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token)) + if !errors.Is(err, ErrCredentialLeaseRevoked) { + t.Fatalf("error = %v, want revoked", err) + } +} + +func TestCredentialBrokerAppRegistrationChangeRevokesLease(t *testing.T) { + installationID := int64(42) + connection := &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, AppRegistrationID: "registration-a", + Status: ConnectionStatusActive, CredentialGeneration: 7, + } + connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + connection.WorkspaceID: connection, + }} + resolver := NewCredentialResolver(connections, nil) + resolver.SetInstallationProvider(fakeBrokerInstallationProvider{resolved: &ResolvedCredential{ + Client: &MockClient{}, Principal: AuthPrincipal{ + Kind: AuthPrincipalApp, Source: ConnectionSourceGitHubAppInstallation, + AppRegistrationID: "registration-a", AppCredentialGeneration: 3, + }, + Capabilities: map[GitHubAppCapability]bool{CapabilityGitRead: true}, + AppRegistrationID: "registration-a", AppCredentialGeneration: 3, + credential: "installation-token", + }}) + broker := NewCredentialBroker(connections, resolver, &fakeBrokerAuthorizer{}) + lease, err := broker.Issue(context.Background(), brokerLeaseRequest()) + if err != nil { + t.Fatal(err) + } + + connection.AppRegistrationID = "registration-b" + _, err = broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token)) + if !errors.Is(err, ErrCredentialLeaseRevoked) { + t.Fatalf("error = %v, want revoked", err) + } +} + +func TestCredentialBrokerDoesNotReturnCredentialAfterConcurrentAppSwitch(t *testing.T) { + installationID := int64(42) + connection := WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, AppRegistrationID: "registration-a", + Status: ConnectionStatusActive, CredentialGeneration: 7, + } + connections := &synchronizedConnectionReader{connection: connection} + provider := &blockingBrokerInstallationProvider{ + started: make(chan struct{}), release: make(chan struct{}), + resolved: &ResolvedCredential{ + Client: &MockClient{}, Principal: AuthPrincipal{ + Kind: AuthPrincipalApp, Source: ConnectionSourceGitHubAppInstallation, + AppRegistrationID: "registration-a", AppCredentialGeneration: 3, + }, + Capabilities: map[GitHubAppCapability]bool{CapabilityGitRead: true}, + CredentialGeneration: 7, AppRegistrationID: "registration-a", + AppCredentialGeneration: 3, credential: "old-installation-token", + }, + } + resolver := NewCredentialResolver(connections, nil) + resolver.SetInstallationProvider(provider) + broker := NewCredentialBroker(connections, resolver, &fakeBrokerAuthorizer{}) + lease, err := broker.Issue(context.Background(), brokerLeaseRequest()) + if err != nil { + t.Fatal(err) + } + + result := make(chan *BrokerCredential, 1) + errs := make(chan error, 1) + go func() { + credential, resolveErr := broker.Resolve( + context.Background(), brokerCredentialRequest(lease.Token), + ) + result <- credential + errs <- resolveErr + }() + <-provider.started + replacement := connection + replacement.AppRegistrationID = "registration-b" + replacement.CredentialGeneration++ + connections.replace(replacement) + close(provider.release) + + if credential, resolveErr := <-result, <-errs; credential != nil || + !errors.Is(resolveErr, ErrCredentialLeaseRevoked) { + t.Fatalf("Resolve() = %+v, %v; want nil, ErrCredentialLeaseRevoked", credential, resolveErr) + } +} + +func TestCredentialBrokerExpiryAndExplicitRevocation(t *testing.T) { + broker, _, _ := newPATCredentialBroker(t) + now := time.Now().UTC() + broker.now = func() time.Time { return now } + req := brokerLeaseRequest() + req.TTL = time.Minute + lease, err := broker.Issue(context.Background(), req) + if err != nil { + t.Fatal(err) + } + broker.now = func() time.Time { return now.Add(2 * time.Minute) } + _, err = broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token)) + if !errors.Is(err, ErrCredentialLeaseExpired) { + t.Fatalf("error = %v, want expired", err) + } + + broker.now = func() time.Time { return now } + lease, err = broker.Issue(context.Background(), req) + if err != nil { + t.Fatal(err) + } + broker.RevokeTask("task-1") + _, err = broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token)) + if !errors.Is(err, ErrCredentialLeaseInvalid) { + t.Fatalf("error = %v, want invalid after revoke", err) + } +} + +func TestCredentialBrokerNeverReturnsPersonalCredential(t *testing.T) { + broker, _, _ := newPATCredentialBroker(t) + lease, err := broker.Issue(context.Background(), brokerLeaseRequest()) + if err != nil { + t.Fatal(err) + } + credential, err := broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token)) + if err != nil { + t.Fatal(err) + } + if credential.Principal.UserID != "" || credential.Principal.Source != ConnectionSourcePAT { + t.Fatalf("principal = %+v", credential.Principal) + } +} + +func TestCredentialBrokerAllowsReadOnlyAppForGitTransport(t *testing.T) { + connection := &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation, + AppRegistrationID: "registration-a", + Status: ConnectionStatusActive, CredentialGeneration: 7, + } + connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + connection.WorkspaceID: connection, + }} + resolver := NewCredentialResolver(connections, nil) + resolver.SetInstallationProvider(fakeBrokerInstallationProvider{resolved: &ResolvedCredential{ + Client: &MockClient{}, Principal: AuthPrincipal{ + Kind: AuthPrincipalApp, Source: ConnectionSourceGitHubAppInstallation, + WorkspaceID: connection.WorkspaceID, AppRegistrationID: "registration-a", + AppCredentialGeneration: 3, + }, + Capabilities: map[GitHubAppCapability]bool{ + CapabilityGitRead: true, + }, + CredentialGeneration: 7, + AppRegistrationID: "registration-a", + AppCredentialGeneration: 3, + credential: "installation-token", + }}) + broker := NewCredentialBroker(connections, resolver, &fakeBrokerAuthorizer{}) + lease, err := broker.Issue(context.Background(), brokerLeaseRequest()) + if err != nil { + t.Fatal(err) + } + + credential, err := broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token)) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if credential.Password != "installation-token" { + t.Fatalf("credential password = %q", credential.Password) + } +} + +func TestCredentialBrokerRejectsAppWithoutGitRead(t *testing.T) { + connection := &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation, + AppRegistrationID: "registration-a", + Status: ConnectionStatusActive, CredentialGeneration: 7, + } + connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + connection.WorkspaceID: connection, + }} + resolver := NewCredentialResolver(connections, nil) + resolver.SetInstallationProvider(fakeBrokerInstallationProvider{resolved: &ResolvedCredential{ + Client: &MockClient{}, Principal: AuthPrincipal{ + Kind: AuthPrincipalApp, Source: ConnectionSourceGitHubAppInstallation, + WorkspaceID: connection.WorkspaceID, AppRegistrationID: "registration-a", + AppCredentialGeneration: 3, + }, + Capabilities: map[GitHubAppCapability]bool{}, + CredentialGeneration: 7, + AppRegistrationID: "registration-a", + AppCredentialGeneration: 3, + credential: "installation-token", + }}) + broker := NewCredentialBroker(connections, resolver, &fakeBrokerAuthorizer{}) + lease, err := broker.Issue(context.Background(), brokerLeaseRequest()) + if err != nil { + t.Fatal(err) + } + + _, err = broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token)) + if !errors.Is(err, ErrGitHubCapabilityDenied) { + t.Fatalf("Resolve() error = %v, want capability denied", err) + } +} diff --git a/apps/backend/internal/github/deployment_app_config.go b/apps/backend/internal/github/deployment_app_config.go new file mode 100644 index 0000000000..6f43b4d281 --- /dev/null +++ b/apps/backend/internal/github/deployment_app_config.go @@ -0,0 +1,83 @@ +package github + +import ( + "context" + "errors" + "strings" +) + +type DeploymentAppSource string + +const ( + DeploymentAppSourceNone DeploymentAppSource = "none" + DeploymentAppSourceManaged DeploymentAppSource = "managed" +) + +type AppRegistrationRuntimeConfig struct { + AppID int64 + ClientID string + ClientSecret string + PrivateKey string + WebhookSecret string + Slug string + PublicBaseURL string +} + +func (c AppRegistrationRuntimeConfig) Validate() error { + if c.AppID <= 0 || strings.TrimSpace(c.ClientID) == "" || c.ClientSecret == "" || + strings.TrimSpace(c.PrivateKey) == "" || c.WebhookSecret == "" || + strings.TrimSpace(c.Slug) == "" || strings.TrimSpace(c.PublicBaseURL) == "" { + return errors.New("GitHub App registration runtime configuration is incomplete") + } + return nil +} + +func (c AppRegistrationRuntimeConfig) PrivateKeyPEM() ([]byte, error) { + if strings.TrimSpace(c.PrivateKey) == "" { + return nil, errors.New("GitHub App private key is required") + } + return []byte(c.PrivateKey), nil +} + +type ResolvedDeploymentAppConfig struct { + Source DeploymentAppSource + Config AppRegistrationRuntimeConfig + Registration *AppRegistration +} + +func ResolveAppRegistrationConfig( + ctx context.Context, + registrationID string, + repository *AppRegistrationRepository, +) (ResolvedDeploymentAppConfig, error) { + if repository == nil { + return ResolvedDeploymentAppConfig{}, errors.New("GitHub App registration repository is not configured") + } + registration, credentials, err := repository.LoadRegistration(ctx, registrationID) + resolved := ResolvedDeploymentAppConfig{ + Source: DeploymentAppSourceManaged, Registration: registration, + } + if err != nil || registration == nil { + return resolved, err + } + if registration.Status != AppRegistrationStatusActive { + return resolved, errors.New("GitHub App registration is not active") + } + resolved.Config = appRegistrationRuntimeConfig(registration, credentials) + if err := resolved.Config.Validate(); err != nil { + return resolved, err + } + return resolved, nil +} + +func appRegistrationRuntimeConfig( + registration *AppRegistration, + credentials DeploymentAppCredentials, +) AppRegistrationRuntimeConfig { + return AppRegistrationRuntimeConfig{ + AppID: registration.AppID, ClientID: registration.ClientID, + ClientSecret: credentials.ClientSecret, PrivateKey: credentials.PrivateKey, + WebhookSecret: credentials.WebhookSecret, Slug: registration.Slug, + PublicBaseURL: registration.PublicBaseURL, + } +} diff --git a/apps/backend/internal/github/deployment_app_config_test.go b/apps/backend/internal/github/deployment_app_config_test.go new file mode 100644 index 0000000000..59b3ed753d --- /dev/null +++ b/apps/backend/internal/github/deployment_app_config_test.go @@ -0,0 +1,34 @@ +package github + +import ( + "context" + "testing" + "time" +) + +func TestResolveAppRegistrationConfigUsesRequestedCatalogID(t *testing.T) { + store := newTestStore(t) + repository := NewAppRegistrationRepository(store, newFakeConnectionSecrets()) + now := time.Now().UTC() + work := newAppRegistration("registration-work", 101, "Work", now) + personal := newAppRegistration("registration-personal", 202, "Personal", now) + for _, item := range []struct { + registration *AppRegistration + credentials DeploymentAppCredentials + }{ + {work, DeploymentAppCredentials{PrivateKey: "work", ClientSecret: "work", WebhookSecret: "work"}}, + {personal, DeploymentAppCredentials{PrivateKey: "personal", ClientSecret: "personal", WebhookSecret: "personal"}}, + } { + if err := repository.SaveRegistration(context.Background(), item.registration, item.credentials); err != nil { + t.Fatal(err) + } + } + resolved, err := ResolveAppRegistrationConfig(context.Background(), personal.ID, repository) + if err != nil { + t.Fatal(err) + } + if resolved.Registration == nil || resolved.Registration.ID != personal.ID || + resolved.Config.AppID != personal.AppID || resolved.Config.PrivateKey != "personal" { + t.Fatalf("resolved registration = %+v", resolved) + } +} diff --git a/apps/backend/internal/github/deployment_app_conversion_client.go b/apps/backend/internal/github/deployment_app_conversion_client.go new file mode 100644 index 0000000000..afe0e289ce --- /dev/null +++ b/apps/backend/internal/github/deployment_app_conversion_client.go @@ -0,0 +1,145 @@ +package github + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + maxManifestConversionResponseSize = 1024 * 1024 + manifestConversionClientTimeout = 30 * time.Second + manifestGitHubAccept = "application/vnd.github+json" + manifestGitHubAPIVersion = "2022-11-28" +) + +type ManifestConversionErrorCode string + +const ( + ManifestConversionCodeInvalid ManifestConversionErrorCode = "manifest_conversion_code_invalid" + ManifestConversionRequestFailed ManifestConversionErrorCode = "manifest_conversion_request_failed" + ManifestConversionRejected ManifestConversionErrorCode = "manifest_conversion_rejected" + ManifestConversionResponseTooLarge ManifestConversionErrorCode = "manifest_conversion_response_too_large" + ManifestConversionInvalidResponse ManifestConversionErrorCode = "manifest_conversion_invalid_response" +) + +type ManifestConversionError struct { + Code ManifestConversionErrorCode + StatusCode int +} + +func (e *ManifestConversionError) Error() string { + switch e.Code { + case ManifestConversionCodeInvalid: + return "GitHub App manifest conversion code is invalid" + case ManifestConversionRequestFailed: + return "GitHub App manifest conversion request failed" + case ManifestConversionRejected: + return "GitHub rejected the App manifest conversion" + case ManifestConversionResponseTooLarge: + return "GitHub App manifest conversion response exceeded the allowed size" + default: + return "GitHub App manifest conversion returned an invalid response" + } +} + +type ManifestConversionOwner struct { + ID int64 `json:"id"` + Login string `json:"login"` + Type string `json:"type"` +} + +type ManifestConversionResult struct { + AppID int64 `json:"id"` + NodeID string `json:"node_id"` + Slug string `json:"slug"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` + Owner ManifestConversionOwner `json:"owner"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + WebhookSecret string `json:"webhook_secret"` + PrivateKeyPEM string `json:"pem"` + Permissions map[string]string `json:"permissions"` + Events []string `json:"events"` +} + +func (r ManifestConversionResult) Redacted() ManifestConversionResult { + r.ClientSecret = "" + r.WebhookSecret = "" + r.PrivateKeyPEM = "" + return r +} + +type ManifestConversionClient struct { + httpClient *http.Client + apiBaseURL string +} + +func NewManifestConversionClient() *ManifestConversionClient { + return &ManifestConversionClient{ + httpClient: &http.Client{ + Timeout: manifestConversionClientTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + apiBaseURL: "https://api.github.com", + } +} + +func (c *ManifestConversionClient) Convert( + ctx context.Context, + conversionCode string, +) (ManifestConversionResult, error) { + if c == nil || c.httpClient == nil || strings.TrimSpace(conversionCode) == "" { + return ManifestConversionResult{}, &ManifestConversionError{Code: ManifestConversionCodeInvalid} + } + endpoint := strings.TrimRight(c.apiBaseURL, "/") + + "/app-manifests/" + url.PathEscape(conversionCode) + "/conversions" + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, http.NoBody) + if err != nil { + return ManifestConversionResult{}, &ManifestConversionError{Code: ManifestConversionRequestFailed} + } + request.Header.Set("Accept", manifestGitHubAccept) + request.Header.Set("X-GitHub-Api-Version", manifestGitHubAPIVersion) + response, err := c.httpClient.Do(request) + if err != nil { + return ManifestConversionResult{}, &ManifestConversionError{Code: ManifestConversionRequestFailed} + } + defer func() { _ = response.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(response.Body, maxManifestConversionResponseSize+1)) + if err != nil { + return ManifestConversionResult{}, &ManifestConversionError{Code: ManifestConversionRequestFailed} + } + if len(body) > maxManifestConversionResponseSize { + return ManifestConversionResult{}, &ManifestConversionError{Code: ManifestConversionResponseTooLarge} + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return ManifestConversionResult{}, &ManifestConversionError{ + Code: ManifestConversionRejected, StatusCode: response.StatusCode, + } + } + var result ManifestConversionResult + if err := json.Unmarshal(body, &result); err != nil { + return ManifestConversionResult{}, &ManifestConversionError{Code: ManifestConversionInvalidResponse} + } + if !validManifestConversionResult(result) { + return ManifestConversionResult{}, &ManifestConversionError{Code: ManifestConversionInvalidResponse} + } + return result, nil +} + +func validManifestConversionResult(result ManifestConversionResult) bool { + return result.AppID > 0 && result.Slug != "" && result.Name != "" && + result.Owner.ID > 0 && result.Owner.Login != "" && + (strings.EqualFold(result.Owner.Type, string(ManifestOwnerUser)) || + strings.EqualFold(result.Owner.Type, string(ManifestOwnerOrganization))) && + result.ClientID != "" && result.ClientSecret != "" && result.WebhookSecret != "" && + result.PrivateKeyPEM != "" && len(result.Permissions) > 0 && len(result.Events) > 0 +} diff --git a/apps/backend/internal/github/deployment_app_conversion_client_test.go b/apps/backend/internal/github/deployment_app_conversion_client_test.go new file mode 100644 index 0000000000..70738269ec --- /dev/null +++ b/apps/backend/internal/github/deployment_app_conversion_client_test.go @@ -0,0 +1,176 @@ +package github + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type manifestConversionRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f manifestConversionRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +func TestManifestConversionParsesIdentityAndGeneratedCredentials(t *testing.T) { + transport := manifestConversionRoundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.Method != http.MethodPost || + request.URL.String() != "https://api.test/app-manifests/code%2Fwith%2Fslashes/conversions" { + t.Fatalf("request = %s %s", request.Method, request.URL) + } + if request.Header.Get("Accept") != "application/vnd.github+json" || + request.Header.Get("X-GitHub-Api-Version") != "2022-11-28" { + t.Fatalf("headers = %#v", request.Header) + } + return &http.Response{ + StatusCode: http.StatusCreated, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{ + "id": 123, + "node_id": "MDM6QXBwMTIz", + "slug": "kandev-acme", + "name": "Kandev Acme", + "html_url": "https://github.com/apps/kandev-acme", + "owner": {"id": 42, "login": "acme", "type": "Organization"}, + "client_id": "Iv1.client-id", + "client_secret": "generated-client-secret", + "webhook_secret": "generated-webhook-secret", + "pem": "-----BEGIN RSA PRIVATE KEY-----\\nprivate-key-material\\n-----END RSA PRIVATE KEY-----", + "permissions": {"contents": "write", "metadata": "read"}, + "events": ["installation", "installation_repositories", "github_app_authorization"] + }`)), + }, nil + }) + client := NewManifestConversionClient() + client.apiBaseURL = "https://api.test" + client.httpClient.Transport = transport + + result, err := client.Convert(context.Background(), "code/with/slashes") + if err != nil { + t.Fatalf("Convert() error = %v", err) + } + if result.AppID != 123 || result.NodeID != "MDM6QXBwMTIz" || result.Slug != "kandev-acme" || + result.Name != "Kandev Acme" || result.Owner.ID != 42 || result.Owner.Login != "acme" || + result.Owner.Type != "Organization" || result.ClientID != "Iv1.client-id" || + result.ClientSecret != "generated-client-secret" || + result.WebhookSecret != "generated-webhook-secret" || + !strings.Contains(result.PrivateKeyPEM, "private-key-material") { + t.Fatalf("Convert() = %+v", result.Redacted()) + } + if result.Permissions["contents"] != "write" || len(result.Events) != 3 { + t.Fatalf("manifest policy = %#v, %#v", result.Permissions, result.Events) + } + redacted := result.Redacted() + if redacted.ClientSecret != "" || redacted.WebhookSecret != "" || redacted.PrivateKeyPEM != "" { + t.Fatalf("Redacted() leaked credentials: %+v", redacted) + } +} + +func TestManifestConversionUsesStableSanitizedErrors(t *testing.T) { + const ( + conversionCode = "one-time-conversion-code" + clientSecret = "generated-client-secret" + webhookSecret = "generated-webhook-secret" + privateKey = "generated-private-key" + ) + tests := []struct { + name string + transport manifestConversionRoundTripFunc + wantCode ManifestConversionErrorCode + wantStatus int + }{ + { + name: "transport failure", + transport: func(*http.Request) (*http.Response, error) { + return nil, errors.New("dial failed for " + conversionCode + " " + clientSecret) + }, + wantCode: ManifestConversionRequestFailed, + }, + { + name: "rejected response", + transport: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnprocessableEntity, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"error":"` + clientSecret + ` ` + webhookSecret + ` ` + privateKey + `"}`, + )), + }, nil + }, + wantCode: ManifestConversionRejected, + wantStatus: http.StatusUnprocessableEntity, + }, + { + name: "malformed response", + transport: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusCreated, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"client_secret":"` + clientSecret + `","pem":"` + privateKey, + )), + }, nil + }, + wantCode: ManifestConversionInvalidResponse, + }, + { + name: "oversized response", + transport: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusCreated, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + strings.Repeat(privateKey, maxManifestConversionResponseSize), + )), + }, nil + }, + wantCode: ManifestConversionResponseTooLarge, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewManifestConversionClient() + client.apiBaseURL = "https://api.test" + client.httpClient.Transport = tt.transport + _, err := client.Convert(context.Background(), conversionCode) + var conversionErr *ManifestConversionError + if !errors.As(err, &conversionErr) || conversionErr.Code != tt.wantCode || + conversionErr.StatusCode != tt.wantStatus { + t.Fatalf("error = %#v, want code %q and status %d", err, tt.wantCode, tt.wantStatus) + } + message := err.Error() + for _, secret := range []string{conversionCode, clientSecret, webhookSecret, privateKey} { + if strings.Contains(message, secret) { + t.Fatalf("error %q leaked %q", message, secret) + } + } + }) + } +} + +func TestManifestConversionRejectsMissingRequiredFieldsWithoutLeakingThem(t *testing.T) { + const clientSecret = "secret-that-must-not-leak" + transport := manifestConversionRoundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusCreated, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"id":123,"client_secret":"` + clientSecret + `"}`, + )), + }, nil + }) + client := NewManifestConversionClient() + client.apiBaseURL = "https://api.test" + client.httpClient.Transport = transport + _, err := client.Convert(context.Background(), "code") + var conversionErr *ManifestConversionError + if !errors.As(err, &conversionErr) || conversionErr.Code != ManifestConversionInvalidResponse { + t.Fatalf("error = %#v, want invalid response", err) + } + if strings.Contains(err.Error(), clientSecret) { + t.Fatalf("error leaked client secret: %v", err) + } +} diff --git a/apps/backend/internal/github/deployment_app_manifest.go b/apps/backend/internal/github/deployment_app_manifest.go new file mode 100644 index 0000000000..39fb85940a --- /dev/null +++ b/apps/backend/internal/github/deployment_app_manifest.go @@ -0,0 +1,344 @@ +package github + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net" + "net/netip" + "net/url" + "regexp" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + DeploymentAppManifestRevision = 1 + deploymentAppManifestFlowTTL = time.Hour + deploymentAppNameMaxLength = 34 +) + +var ( + ErrDeploymentAppManifestOwnerInvalid = errors.New("GitHub App owner is invalid") + ErrAppRegistrationIDInvalid = errors.New("GitHub App registration ID is invalid") + ErrAppRegistrationVisibilityInvalid = errors.New("GitHub App visibility is invalid") + ErrDeploymentAppManifestStateUnavailable = errors.New( + "GitHub App manifest state is expired, consumed, or invalid", + ) + ErrPublicGitHubBaseURLInvalid = errors.New( + "public GitHub base URL must be an HTTPS origin without credentials, path, query, or fragment", + ) + ErrPublicGitHubBaseURLNotGlobal = errors.New( + "public GitHub base URL must resolve only to globally routable addresses", + ) + ErrPublicGitHubBaseURLUnresolvable = errors.New( + "public GitHub base URL could not be resolved", + ) + githubOwnerLoginPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`) + globallyRoutableSpecialAddressRanges = mustParseAddressPrefixes( + "192.0.0.9/32", + "192.0.0.10/32", + "2001:1::1/128", + "2001:1::2/128", + "2001:1::3/128", + "2001:3::/32", + "2001:4:112::/48", + "2001:20::/28", + "2001:30::/28", + ) + nonGlobalAddressRanges = mustParseAddressPrefixes( + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + "::/128", + "::1/128", + "64:ff9b:1::/48", + "100::/64", + "100:0:0:1::/64", + "2001::/23", + "2001:db8::/32", + "2002::/16", + "3fff::/20", + "5f00::/16", + "fc00::/7", + "fe80::/10", + "fec0::/10", + "ff00::/8", + ) +) + +type ManifestOwnerType string + +const ( + ManifestOwnerUser ManifestOwnerType = "user" + ManifestOwnerOrganization ManifestOwnerType = "organization" +) + +type DeploymentAppManifestHook struct { + URL string `json:"url"` + Active bool `json:"active"` +} + +type DeploymentAppManifest struct { + Name string `json:"name"` + Description string `json:"description"` + URL string `json:"url"` + HookAttributes DeploymentAppManifestHook `json:"hook_attributes"` + RedirectURL string `json:"redirect_url"` + CallbackURLs []string `json:"callback_urls"` + SetupURL string `json:"setup_url"` + Public bool `json:"public"` + DefaultPermissions map[string]string `json:"default_permissions"` + DefaultEvents []string `json:"default_events"` + RequestOAuthOnInstall bool `json:"request_oauth_on_install"` + SetupOnUpdate bool `json:"setup_on_update"` +} + +type DeploymentAppManifestSubmission struct { + Revision int + RegistrationURL string + Manifest DeploymentAppManifest +} + +type AppRegistrationManifestRequest struct { + RegistrationID string + OwnerType ManifestOwnerType + OwnerLogin string + PublicBaseURL string + Visibility AppRegistrationVisibility +} + +func BuildAppRegistrationManifest( + request AppRegistrationManifestRequest, +) (DeploymentAppManifestSubmission, error) { + registrationID, err := uuid.Parse(request.RegistrationID) + if err != nil || registrationID.String() != request.RegistrationID { + return DeploymentAppManifestSubmission{}, ErrAppRegistrationIDInvalid + } + public, err := manifestVisibility(request.Visibility) + if err != nil { + return DeploymentAppManifestSubmission{}, err + } + submission, err := BuildDeploymentAppManifest( + request.OwnerType, request.OwnerLogin, request.PublicBaseURL, + ) + if err != nil { + return DeploymentAppManifestSubmission{}, err + } + baseURL := strings.TrimRight(submission.Manifest.URL, "/") + + "/api/v1/github/app/registrations/" + registrationID.String() + submission.Manifest.HookAttributes.URL = baseURL + "/webhook" + submission.Manifest.RedirectURL = baseURL + "/manifest/callback" + submission.Manifest.CallbackURLs = []string{baseURL + "/personal/callback"} + submission.Manifest.SetupURL = baseURL + "/install/callback" + submission.Manifest.Public = public + return submission, nil +} + +func manifestVisibility(visibility AppRegistrationVisibility) (bool, error) { + switch visibility { + case "", AppRegistrationVisibilityPrivate: + return false, nil + case AppRegistrationVisibilityPublic: + return true, nil + default: + return false, ErrAppRegistrationVisibilityInvalid + } +} + +type PublicGitHubBaseURLResolver interface { + LookupNetIP(context.Context, string, string) ([]netip.Addr, error) +} + +func ValidatePublicGitHubBaseURL( + ctx context.Context, + rawURL string, + resolver PublicGitHubBaseURLResolver, +) (string, error) { + parsed, err := parsePublicGitHubBaseURL(rawURL) + if err != nil { + return "", err + } + hostname := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), ".")) + if address, parseErr := netip.ParseAddr(hostname); parseErr == nil { + if !isGloballyRoutable(address) { + return "", ErrPublicGitHubBaseURLNotGlobal + } + return canonicalPublicOrigin(parsed, hostname, address), nil + } + if resolver == nil { + resolver = net.DefaultResolver + } + addresses, err := resolver.LookupNetIP(ctx, "ip", hostname) + if err != nil || len(addresses) == 0 { + return "", ErrPublicGitHubBaseURLUnresolvable + } + for _, address := range addresses { + if !isGloballyRoutable(address) { + return "", ErrPublicGitHubBaseURLNotGlobal + } + } + return canonicalPublicOrigin(parsed, hostname, netip.Addr{}), nil +} + +func BuildDeploymentAppManifest( + ownerType ManifestOwnerType, + ownerLogin, publicBaseURL string, +) (DeploymentAppManifestSubmission, error) { + if !validManifestOwner(ownerType, ownerLogin) { + return DeploymentAppManifestSubmission{}, ErrDeploymentAppManifestOwnerInvalid + } + baseURL, err := canonicalManifestBaseURL(publicBaseURL) + if err != nil { + return DeploymentAppManifestSubmission{}, err + } + return DeploymentAppManifestSubmission{ + Revision: DeploymentAppManifestRevision, + RegistrationURL: manifestRegistrationURL(ownerType, ownerLogin), + Manifest: DeploymentAppManifest{ + Name: deploymentAppName(ownerLogin, baseURL), + Description: "Repository automation for Kandev workspaces.", + URL: baseURL, + HookAttributes: DeploymentAppManifestHook{ + URL: baseURL + "/api/v1/github/app/webhook", Active: true, + }, + RedirectURL: baseURL + "/api/v1/github/app/registration/callback", + CallbackURLs: []string{baseURL + "/api/v1/github/personal-connection/callback"}, + SetupURL: baseURL + "/api/v1/github/app/install/callback", + Public: false, + DefaultPermissions: map[string]string{ + "actions": "read", "administration": "read", "checks": "read", + "contents": "write", "issues": "write", "members": "read", + "metadata": "read", "pull_requests": "write", "statuses": "read", + "workflows": "write", + }, + DefaultEvents: []string{ + "installation", "installation_repositories", "github_app_authorization", + }, + RequestOAuthOnInstall: true, + SetupOnUpdate: false, + }, + }, nil +} + +func DeploymentAppManifestFlowExpiresAt(createdAt time.Time) time.Time { + return createdAt.UTC().Add(deploymentAppManifestFlowTTL) +} + +func ValidateDeploymentAppManifestFlow(expiresAt time.Time, consumedAt *time.Time, now time.Time) error { + if consumedAt != nil || expiresAt.IsZero() || !expiresAt.After(now.UTC()) { + return ErrDeploymentAppManifestStateUnavailable + } + return nil +} + +func parsePublicGitHubBaseURL(rawURL string) (*url.URL, error) { + if strings.TrimSpace(rawURL) != rawURL || rawURL == "" { + return nil, ErrPublicGitHubBaseURLInvalid + } + parsed, err := url.Parse(rawURL) + if err != nil || !validPublicOriginShape(parsed) { + return nil, ErrPublicGitHubBaseURLInvalid + } + if parsed.Hostname() == "" || strings.HasSuffix(parsed.Host, ":") { + return nil, ErrPublicGitHubBaseURLInvalid + } + return parsed, nil +} + +func validPublicOriginShape(parsed *url.URL) bool { + return parsed.Scheme == "https" && parsed.Host != "" && parsed.User == nil && + parsed.RawQuery == "" && !parsed.ForceQuery && parsed.Fragment == "" && + (parsed.Path == "" || parsed.Path == "/") && parsed.RawPath == "" && parsed.Opaque == "" +} + +func canonicalManifestBaseURL(rawURL string) (string, error) { + parsed, err := parsePublicGitHubBaseURL(rawURL) + if err != nil { + return "", err + } + hostname := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), ".")) + address, _ := netip.ParseAddr(hostname) + return canonicalPublicOrigin(parsed, hostname, address), nil +} + +func canonicalPublicOrigin(parsed *url.URL, hostname string, address netip.Addr) string { + host := hostname + if address.IsValid() && address.Is6() { + host = "[" + hostname + "]" + } + if port := parsed.Port(); port != "" && port != "443" { + host = net.JoinHostPort(hostname, port) + } + return "https://" + host +} + +func validManifestOwner(ownerType ManifestOwnerType, ownerLogin string) bool { + return (ownerType == ManifestOwnerUser || ownerType == ManifestOwnerOrganization) && + githubOwnerLoginPattern.MatchString(ownerLogin) +} + +func manifestRegistrationURL(ownerType ManifestOwnerType, ownerLogin string) string { + if ownerType == ManifestOwnerOrganization { + return "https://github.com/organizations/" + url.PathEscape(ownerLogin) + "/settings/apps/new" + } + return "https://github.com/settings/apps/new" +} + +func deploymentAppName(ownerLogin, baseURL string) string { + digest := sha256.Sum256([]byte(ownerLogin + "\x00" + baseURL)) + suffix := hex.EncodeToString(digest[:4]) + prefix := "Kandev " + ownerLogin + maxPrefixLength := deploymentAppNameMaxLength - len(suffix) - 1 + if len(prefix) > maxPrefixLength { + prefix = strings.TrimRight(prefix[:maxPrefixLength], "-") + } + return fmt.Sprintf("%s-%s", prefix, suffix) +} + +func isGloballyRoutable(address netip.Addr) bool { + if !address.IsValid() || address.Zone() != "" { + return false + } + address = address.Unmap() + if !address.IsGlobalUnicast() || address.IsPrivate() || address.IsLoopback() || + address.IsLinkLocalUnicast() || address.IsLinkLocalMulticast() || address.IsMulticast() || + address.IsUnspecified() { + return false + } + for _, addressRange := range globallyRoutableSpecialAddressRanges { + if addressRange.Contains(address) { + return true + } + } + for _, addressRange := range nonGlobalAddressRanges { + if addressRange.Contains(address) { + return false + } + } + return true +} + +func mustParseAddressPrefixes(values ...string) []netip.Prefix { + prefixes := make([]netip.Prefix, 0, len(values)) + for _, value := range values { + prefixes = append(prefixes, netip.MustParsePrefix(value)) + } + return prefixes +} diff --git a/apps/backend/internal/github/deployment_app_manifest_test.go b/apps/backend/internal/github/deployment_app_manifest_test.go new file mode 100644 index 0000000000..da3f596814 --- /dev/null +++ b/apps/backend/internal/github/deployment_app_manifest_test.go @@ -0,0 +1,319 @@ +package github + +import ( + "context" + "errors" + "net/netip" + "reflect" + "testing" + "time" +) + +type manifestResolverFunc func(context.Context, string, string) ([]netip.Addr, error) + +func (f manifestResolverFunc) LookupNetIP( + ctx context.Context, + network, host string, +) ([]netip.Addr, error) { + return f(ctx, network, host) +} + +func TestDeploymentAppManifestExactPolicyAndURLs(t *testing.T) { + resolver := manifestResolverFunc(func(_ context.Context, network, host string) ([]netip.Addr, error) { + if network != "ip" || host != "kandev.example" { + t.Fatalf("LookupNetIP() = %q, %q", network, host) + } + return []netip.Addr{netip.MustParseAddr("203.0.114.10")}, nil + }) + baseURL, err := ValidatePublicGitHubBaseURL( + context.Background(), + "https://KANDEV.example:443/", + resolver, + ) + if err != nil { + t.Fatalf("ValidatePublicGitHubBaseURL() error = %v", err) + } + + submission, err := BuildDeploymentAppManifest(ManifestOwnerUser, "octocat", baseURL) + if err != nil { + t.Fatalf("BuildDeploymentAppManifest() error = %v", err) + } + if submission.Revision != DeploymentAppManifestRevision { + t.Fatalf("Revision = %d, want %d", submission.Revision, DeploymentAppManifestRevision) + } + if submission.RegistrationURL != "https://github.com/settings/apps/new" { + t.Fatalf("RegistrationURL = %q", submission.RegistrationURL) + } + manifest := submission.Manifest + if manifest.Name == "" || len(manifest.Name) > 34 { + t.Fatalf("Name = %q, want non-empty GitHub-compatible name", manifest.Name) + } + if manifest.URL != "https://kandev.example" || + manifest.RedirectURL != "https://kandev.example/api/v1/github/app/registration/callback" || + !reflect.DeepEqual(manifest.CallbackURLs, []string{ + "https://kandev.example/api/v1/github/personal-connection/callback", + }) || + manifest.SetupURL != "https://kandev.example/api/v1/github/app/install/callback" || + manifest.HookAttributes.URL != "https://kandev.example/api/v1/github/app/webhook" || + !manifest.HookAttributes.Active { + t.Fatalf("manifest URLs = %+v", manifest) + } + if manifest.Public || !manifest.RequestOAuthOnInstall || manifest.SetupOnUpdate { + t.Fatalf("manifest flags = public:%v oauth:%v setup_on_update:%v", + manifest.Public, manifest.RequestOAuthOnInstall, manifest.SetupOnUpdate) + } + wantPermissions := map[string]string{ + "actions": "read", + "administration": "read", + "checks": "read", + "contents": "write", + "issues": "write", + "members": "read", + "metadata": "read", + "pull_requests": "write", + "statuses": "read", + "workflows": "write", + } + if !reflect.DeepEqual(manifest.DefaultPermissions, wantPermissions) { + t.Fatalf("DefaultPermissions = %#v, want %#v", manifest.DefaultPermissions, wantPermissions) + } + wantEvents := []string{"installation", "installation_repositories", "github_app_authorization"} + if !reflect.DeepEqual(manifest.DefaultEvents, wantEvents) { + t.Fatalf("DefaultEvents = %#v, want %#v", manifest.DefaultEvents, wantEvents) + } +} + +func TestDeploymentAppManifestUsesPreallocatedRegistrationRoutes(t *testing.T) { + const registrationID = "11111111-1111-4111-8111-111111111111" + submission, err := BuildAppRegistrationManifest(AppRegistrationManifestRequest{ + RegistrationID: registrationID, + OwnerType: ManifestOwnerOrganization, OwnerLogin: "acme", + PublicBaseURL: "https://kandev.example", + }) + if err != nil { + t.Fatal(err) + } + wantPrefix := "https://kandev.example/api/v1/github/app/registrations/" + registrationID + manifest := submission.Manifest + if manifest.RedirectURL != wantPrefix+"/manifest/callback" || + manifest.SetupURL != wantPrefix+"/install/callback" || + manifest.HookAttributes.URL != wantPrefix+"/webhook" || + !reflect.DeepEqual(manifest.CallbackURLs, []string{wantPrefix + "/personal/callback"}) { + t.Fatalf("registration-specific manifest URLs = %+v", manifest) + } + if manifest.Public { + t.Fatal("manifest without explicit visibility is public") + } +} + +func TestDeploymentAppManifestRequiresExplicitPublicVisibility(t *testing.T) { + request := AppRegistrationManifestRequest{ + RegistrationID: "33333333-3333-4333-8333-333333333333", + OwnerType: ManifestOwnerUser, OwnerLogin: "octocat", + PublicBaseURL: "https://kandev.example", + } + privateSubmission, err := BuildAppRegistrationManifest(request) + if err != nil { + t.Fatal(err) + } + if privateSubmission.Manifest.Public { + t.Fatal("default manifest visibility is public") + } + request.Visibility = AppRegistrationVisibilityPublic + publicSubmission, err := BuildAppRegistrationManifest(request) + if err != nil { + t.Fatal(err) + } + if !publicSubmission.Manifest.Public { + t.Fatal("explicit public manifest visibility was ignored") + } + request.Visibility = "marketplace" + if _, err := BuildAppRegistrationManifest(request); !errors.Is(err, ErrAppRegistrationVisibilityInvalid) { + t.Fatalf("invalid visibility error = %v", err) + } +} + +func TestDeploymentAppManifestOrganizationOwnerURL(t *testing.T) { + submission, err := BuildDeploymentAppManifest( + ManifestOwnerOrganization, + "kdlbs", + "https://kandev.example", + ) + if err != nil { + t.Fatalf("BuildDeploymentAppManifest() error = %v", err) + } + if submission.RegistrationURL != "https://github.com/organizations/kdlbs/settings/apps/new" { + t.Fatalf("RegistrationURL = %q", submission.RegistrationURL) + } +} + +func TestDeploymentAppManifestRejectsInvalidOwner(t *testing.T) { + tests := []struct { + name string + ownerType ManifestOwnerType + login string + }{ + {name: "unknown owner", ownerType: "enterprise", login: "acme"}, + {name: "missing login", ownerType: ManifestOwnerUser}, + {name: "invalid login", ownerType: ManifestOwnerOrganization, login: "acme/widgets"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := BuildDeploymentAppManifest(tt.ownerType, tt.login, "https://kandev.example") + if !errors.Is(err, ErrDeploymentAppManifestOwnerInvalid) { + t.Fatalf("error = %v, want %v", err, ErrDeploymentAppManifestOwnerInvalid) + } + }) + } +} + +func TestPublicGitHubBaseURLRejectsUnsafeOrigins(t *testing.T) { + resolver := manifestResolverFunc(func(_ context.Context, _, _ string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("203.0.114.10")}, nil + }) + tests := []string{ + "http://kandev.example", + "https://user:pass@kandev.example", + "https://kandev.example/path", + "https://kandev.example?debug=true", + "https://kandev.example#fragment", + "https://127.0.0.1", + "https://10.0.0.1", + "https://169.254.1.2", + "https://[::1]", + "https://[fd00::1]", + "https://[100:0:0:1::1]", + "https://[2001::1]", + "https://[2001:2::1]", + "https://[2001:10::1]", + "https://[2001:db8::1]", + "https://[2002::1]", + "https://[3fff::1]", + "https://[5f00::1]", + "https://[fec0::1]", + } + for _, rawURL := range tests { + t.Run(rawURL, func(t *testing.T) { + _, err := ValidatePublicGitHubBaseURL(context.Background(), rawURL, resolver) + if err == nil { + t.Fatal("error = nil, want unsafe origin rejection") + } + }) + } +} + +func TestPublicGitHubBaseURLAllowsIANAReachableSpecialPurposeAddresses(t *testing.T) { + for _, rawURL := range []string{ + "https://192.0.0.9", + "https://192.0.0.10", + "https://[2001:1::1]", + "https://[2001:1::2]", + "https://[2001:1::3]", + "https://[2001:3::1]", + "https://[2001:4:112::1]", + "https://[2001:20::1]", + "https://[2001:30::1]", + } { + t.Run(rawURL, func(t *testing.T) { + canonical, err := ValidatePublicGitHubBaseURL(context.Background(), rawURL, nil) + if err != nil { + t.Fatalf("ValidatePublicGitHubBaseURL() error = %v", err) + } + if canonical != rawURL { + t.Fatalf("canonical URL = %q, want %q", canonical, rawURL) + } + }) + } +} + +func TestPublicGitHubBaseURLRejectsSiteLocalIPv6DNSResult(t *testing.T) { + resolver := manifestResolverFunc(func(_ context.Context, _, host string) ([]netip.Addr, error) { + if host != "site-local.example" { + t.Fatalf("host = %q", host) + } + return []netip.Addr{ + netip.MustParseAddr("203.0.114.10"), + netip.MustParseAddr("fec0::1"), + }, nil + }) + _, err := ValidatePublicGitHubBaseURL(context.Background(), "https://site-local.example", resolver) + if !errors.Is(err, ErrPublicGitHubBaseURLNotGlobal) { + t.Fatalf("error = %v, want %v", err, ErrPublicGitHubBaseURLNotGlobal) + } +} + +func TestPublicGitHubBaseURLRequiresEveryDNSResultToBeGlobal(t *testing.T) { + resolver := manifestResolverFunc(func(_ context.Context, _, host string) ([]netip.Addr, error) { + if host != "mixed.example" { + t.Fatalf("host = %q", host) + } + return []netip.Addr{ + netip.MustParseAddr("203.0.114.10"), + netip.MustParseAddr("192.168.1.10"), + }, nil + }) + _, err := ValidatePublicGitHubBaseURL(context.Background(), "https://mixed.example", resolver) + if !errors.Is(err, ErrPublicGitHubBaseURLNotGlobal) { + t.Fatalf("error = %v, want %v", err, ErrPublicGitHubBaseURLNotGlobal) + } +} + +func TestPublicGitHubBaseURLRejectsEmptyOrFailedDNS(t *testing.T) { + tests := []struct { + name string + resolver manifestResolverFunc + }{ + { + name: "empty result", + resolver: func(context.Context, string, string) ([]netip.Addr, error) { + return nil, nil + }, + }, + { + name: "lookup failure", + resolver: func(context.Context, string, string) ([]netip.Addr, error) { + return nil, errors.New("resolver details") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ValidatePublicGitHubBaseURL( + context.Background(), + "https://unresolved.example", + tt.resolver, + ) + if !errors.Is(err, ErrPublicGitHubBaseURLUnresolvable) { + t.Fatalf("error = %v, want %v", err, ErrPublicGitHubBaseURLUnresolvable) + } + if err != nil && errors.Is(err, context.Canceled) { + t.Fatalf("error unexpectedly exposed resolver details: %v", err) + } + }) + } +} + +func TestDeploymentAppManifestFlowExpiresAfterOneHourAndRejectsReplay(t *testing.T) { + createdAt := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + expiresAt := DeploymentAppManifestFlowExpiresAt(createdAt) + if got := expiresAt.Sub(createdAt); got != time.Hour { + t.Fatalf("flow TTL = %v, want %v", got, time.Hour) + } + if err := ValidateDeploymentAppManifestFlow(expiresAt, nil, expiresAt.Add(-time.Nanosecond)); err != nil { + t.Fatalf("valid flow error = %v", err) + } + if err := ValidateDeploymentAppManifestFlow(expiresAt, nil, expiresAt); !errors.Is( + err, + ErrDeploymentAppManifestStateUnavailable, + ) { + t.Fatalf("expired flow error = %v, want unavailable", err) + } + consumedAt := createdAt.Add(time.Minute) + if err := ValidateDeploymentAppManifestFlow( + expiresAt, + &consumedAt, + createdAt.Add(2*time.Minute), + ); !errors.Is(err, ErrDeploymentAppManifestStateUnavailable) { + t.Fatalf("replayed flow error = %v, want unavailable", err) + } +} diff --git a/apps/backend/internal/github/deployment_app_store.go b/apps/backend/internal/github/deployment_app_store.go new file mode 100644 index 0000000000..dddbac177d --- /dev/null +++ b/apps/backend/internal/github/deployment_app_store.go @@ -0,0 +1,706 @@ +package github + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + AppRegistrationCredentialsSecretPrefix = "github:app-registration:" + DeploymentAppCredentialsSecretPrefix = AppRegistrationCredentialsSecretPrefix + DeploymentAppCredentialBundleVersion = 1 + appRegistrationCredentialsSecretName = "GitHub App registration credentials" +) + +var ErrDeploymentAppFlowUnavailable = errors.New("GitHub App registration flow is expired, consumed, or missing") +var ErrDeploymentAppInUse = errors.New("GitHub App registration is in use") +var ErrAppRegistrationImportPreparationUnavailable = errors.New("GitHub App import preparation is expired, consumed, or invalid") +var ErrAppRegistrationCredentialCleanup = errors.New("GitHub App credentials could not be deleted; retry the operation") +var ErrAppRegistrationDeletionFailed = errors.New("GitHub App registration could not be deleted; retry the operation") + +type DeploymentAppCredentials struct { + PrivateKey string `json:"private_key"` + ClientSecret string `json:"client_secret"` + WebhookSecret string `json:"webhook_secret"` +} + +type DeploymentAppCredentialBundle struct { + Version int `json:"version"` + Generation int64 `json:"generation"` + Credentials DeploymentAppCredentials `json:"credentials"` +} + +type DeploymentAppRepository struct { + store *Store + secrets ConnectionSecretStore +} + +type AppRegistrationRepository = DeploymentAppRepository + +func NewDeploymentAppRepository(store *Store, secrets ConnectionSecretStore) *DeploymentAppRepository { + return &DeploymentAppRepository{store: store, secrets: secrets} +} + +func NewAppRegistrationRepository(store *Store, secrets ConnectionSecretStore) *AppRegistrationRepository { + return NewDeploymentAppRepository(store, secrets) +} + +const appRegistrationSelect = ` + SELECT id, source, display_name, github_host, app_id, client_id, slug, owner_login, owner_type, + visibility, public_base_url, COALESCE(created_for_workspace_id, '') AS created_for_workspace_id, + credential_generation, credential_secret_id, status, webhook_status, last_webhook_at, + COALESCE(last_error, '') AS last_error, created_at, updated_at + FROM github_app_registrations` + +const appRegistrationInsertSQL = ` + INSERT INTO github_app_registrations ( + id, source, display_name, github_host, app_id, client_id, slug, owner_login, owner_type, + visibility, public_base_url, created_for_workspace_id, credential_generation, + credential_secret_id, status, webhook_status, last_webhook_at, last_error, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + +const appRegistrationUpsertSQL = appRegistrationInsertSQL + ` + ON CONFLICT(id) DO UPDATE SET + source = excluded.source, + display_name = excluded.display_name, + github_host = excluded.github_host, + app_id = excluded.app_id, + client_id = excluded.client_id, + slug = excluded.slug, + owner_login = excluded.owner_login, + owner_type = excluded.owner_type, + visibility = excluded.visibility, + public_base_url = excluded.public_base_url, + created_for_workspace_id = excluded.created_for_workspace_id, + credential_generation = excluded.credential_generation, + credential_secret_id = excluded.credential_secret_id, + status = excluded.status, + webhook_status = excluded.webhook_status, + last_webhook_at = excluded.last_webhook_at, + last_error = excluded.last_error, + updated_at = excluded.updated_at` + +func (s *Store) GetAppRegistration(ctx context.Context, registrationID string) (*AppRegistration, error) { + var registration AppRegistration + err := s.ro.GetContext(ctx, ®istration, + s.ro.Rebind(appRegistrationSelect+` WHERE id = ?`), registrationID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return ®istration, err +} + +func (s *Store) GetAppRegistrationByGitHubApp( + ctx context.Context, + githubHost string, + appID int64, +) (*AppRegistration, error) { + var registration AppRegistration + err := s.ro.GetContext(ctx, ®istration, + s.ro.Rebind(appRegistrationSelect+` WHERE github_host = ? AND app_id = ?`), githubHost, appID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return ®istration, err +} + +func (s *Store) ListAppRegistrations(ctx context.Context) ([]*AppRegistration, error) { + var registrations []*AppRegistration + err := s.ro.SelectContext(ctx, ®istrations, appRegistrationSelect+` ORDER BY display_name, id`) + return registrations, err +} + +func (s *Store) UpsertDeploymentAppRegistration( + ctx context.Context, + registration *DeploymentAppRegistration, +) error { + arguments, err := appRegistrationWriteArguments(registration) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, s.db.Rebind(appRegistrationUpsertSQL), arguments...) + return err +} + +func (s *Store) InsertAppRegistration(ctx context.Context, registration *AppRegistration) error { + arguments, err := appRegistrationWriteArguments(registration) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, s.db.Rebind(appRegistrationInsertSQL), arguments...) + return err +} + +func appRegistrationWriteArguments(registration *AppRegistration) ([]any, error) { + if registration == nil || strings.TrimSpace(registration.ID) == "" { + return nil, errors.New("GitHub App registration ID is required") + } + now := time.Now().UTC() + if registration.CreatedAt.IsZero() { + registration.CreatedAt = now + } + registration.UpdatedAt = now + return []any{ + registration.ID, registration.Source, registration.DisplayName, registration.GitHubHost, + registration.AppID, registration.ClientID, registration.Slug, registration.OwnerLogin, + registration.OwnerType, registration.Visibility, registration.PublicBaseURL, + nullString(registration.CreatedForWorkspaceID), registration.CredentialGeneration, + registration.CredentialSecretID, registration.Status, registration.WebhookStatus, + registration.LastWebhookAt, nullString(registration.LastError), registration.CreatedAt, + registration.UpdatedAt, + }, nil +} + +func (s *Store) DeleteAppRegistration(ctx context.Context, registrationID string) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind( + `DELETE FROM github_app_registrations WHERE id = ?`), registrationID) + return err +} + +func (s *Store) CountAppRegistrationReferences(ctx context.Context, registrationID string) (int, error) { + var count int + err := s.ro.GetContext(ctx, &count, s.ro.Rebind(` + SELECT + (SELECT COUNT(*) FROM github_workspace_connections WHERE app_registration_id = ?) + + (SELECT COUNT(*) FROM github_user_connections WHERE app_registration_id = ?) + + (SELECT COUNT(*) FROM github_auth_flows WHERE app_registration_id = ?)`), + registrationID, registrationID, registrationID) + return count, err +} + +func (s *Store) CountAppRegistrationBindings(ctx context.Context, registrationID string) (int, error) { + var count int + err := s.ro.GetContext(ctx, &count, s.ro.Rebind(` + SELECT + (SELECT COUNT(*) FROM github_workspace_connections WHERE app_registration_id = ?) + + (SELECT COUNT(*) FROM github_user_connections WHERE app_registration_id = ?)`), + registrationID, registrationID) + return count, err +} + +func (s *Store) CountAppRegistrationWorkspaceBindings( + ctx context.Context, + registrationID string, +) (int, error) { + var count int + err := s.ro.GetContext(ctx, &count, s.ro.Rebind(` + SELECT COUNT(*) FROM github_workspace_connections WHERE app_registration_id = ?`), + registrationID) + return count, err +} + +func (s *Store) RenameAppRegistration( + ctx context.Context, + registrationID, displayName string, +) (*AppRegistration, error) { + result, err := s.db.ExecContext(ctx, s.db.Rebind(` + UPDATE github_app_registrations SET display_name = ?, updated_at = ? WHERE id = ?`), + displayName, time.Now().UTC(), registrationID) + if err != nil { + return nil, err + } + count, err := result.RowsAffected() + if err != nil || count == 0 { + return nil, err + } + return s.GetAppRegistration(ctx, registrationID) +} + +func (s *Store) DeleteAppRegistrationData(ctx context.Context, registrationID string) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + for _, query := range []string{ + `DELETE FROM github_auth_flows WHERE app_registration_id = ?`, + `DELETE FROM github_webhook_deliveries WHERE app_registration_id = ?`, + `DELETE FROM github_app_import_preparations WHERE registration_id = ?`, + `DELETE FROM github_app_registration_flows WHERE registration_id = ?`, + `DELETE FROM github_app_registrations WHERE id = ?`, + } { + if _, err := tx.ExecContext(ctx, tx.Rebind(query), registrationID); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *Store) MarkAppRegistrationInvalid( + ctx context.Context, + registrationID, reason string, +) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind(` + UPDATE github_app_registrations + SET status = ?, last_error = ?, updated_at = ? + WHERE id = ?`), AppRegistrationStatusInvalid, reason, time.Now().UTC(), registrationID) + return err +} + +func (s *Store) CreateAppRegistrationImportPreparation( + ctx context.Context, + preparation *AppRegistrationImportPreparation, +) error { + if preparation == nil || strings.TrimSpace(preparation.RegistrationID) == "" || + strings.TrimSpace(preparation.WorkspaceID) == "" || strings.TrimSpace(preparation.UserID) == "" || + strings.TrimSpace(preparation.PublicBaseURL) == "" { + return errors.New("GitHub App import preparation is invalid") + } + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, tx.Rebind(` + UPDATE github_app_import_preparations SET consumed_at = ? + WHERE workspace_id = ? AND user_id = ? AND consumed_at IS NULL`), + preparation.CreatedAt, preparation.WorkspaceID, preparation.UserID); err != nil { + return err + } + _, err = tx.ExecContext(ctx, tx.Rebind(` + INSERT INTO github_app_import_preparations ( + registration_id, workspace_id, user_id, public_base_url, expires_at, consumed_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`), + preparation.RegistrationID, preparation.WorkspaceID, preparation.UserID, + preparation.PublicBaseURL, preparation.ExpiresAt, preparation.ConsumedAt, preparation.CreatedAt) + if err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) GetAppRegistrationImportPreparation( + ctx context.Context, + registrationID string, +) (*AppRegistrationImportPreparation, error) { + var preparation AppRegistrationImportPreparation + err := s.ro.GetContext(ctx, &preparation, s.ro.Rebind(` + SELECT registration_id, workspace_id, user_id, public_base_url, + expires_at, consumed_at, created_at + FROM github_app_import_preparations WHERE registration_id = ?`), registrationID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &preparation, err +} + +func (s *Store) ConsumeAppRegistrationImportPreparation( + ctx context.Context, + registrationID, workspaceID, userID, publicBaseURL string, + now time.Time, +) (*AppRegistrationImportPreparation, error) { + result, err := s.db.ExecContext(ctx, s.db.Rebind(` + UPDATE github_app_import_preparations SET consumed_at = ? + WHERE registration_id = ? AND workspace_id = ? AND user_id = ? AND public_base_url = ? + AND consumed_at IS NULL AND expires_at > ? + AND NOT EXISTS ( + SELECT 1 FROM github_app_registrations registration + WHERE registration.id = github_app_import_preparations.registration_id + )`), now, registrationID, workspaceID, userID, publicBaseURL, now) + if err != nil { + return nil, err + } + count, err := result.RowsAffected() + if err != nil { + return nil, err + } + if count != 1 { + return nil, ErrAppRegistrationImportPreparationUnavailable + } + return s.GetAppRegistrationImportPreparation(ctx, registrationID) +} + +func (s *Store) DeleteAppRegistrationImportPreparationsByWorkspace( + ctx context.Context, + workspaceID string, +) error { + if strings.TrimSpace(workspaceID) == "" { + _, err := s.db.ExecContext(ctx, `DELETE FROM github_app_import_preparations`) + return err + } + _, err := s.db.ExecContext(ctx, s.db.Rebind( + `DELETE FROM github_app_import_preparations WHERE workspace_id = ?`), workspaceID) + return err +} + +func (r *DeploymentAppRepository) SaveRegistration( + ctx context.Context, + registration *AppRegistration, + credentials DeploymentAppCredentials, +) error { + if r == nil || r.store == nil || r.secrets == nil || registration == nil { + return errors.New("GitHub App registration repository is not configured") + } + if strings.TrimSpace(registration.ID) == "" { + return errors.New("GitHub App registration ID is required") + } + if err := validateDeploymentAppCredentials(credentials); err != nil { + return err + } + r.store.deploymentAppPersistenceMu.Lock() + defer r.store.deploymentAppPersistenceMu.Unlock() + + previous, err := r.store.GetAppRegistration(ctx, registration.ID) + if err != nil { + return err + } + if previous != nil && registration.CredentialGeneration != previous.CredentialGeneration+1 { + return errors.New("GitHub App credential generation is not the next generation") + } + if previous == nil && registration.CredentialGeneration != 1 { + return errors.New("initial GitHub App credential generation must be 1") + } + return r.saveRegistrationLocked(ctx, previous, registration, credentials) +} + +// CreateRegistration persists a new registration and never updates an +// existing catalog ID, including when another writer wins after preflight. +func (r *DeploymentAppRepository) CreateRegistration( + ctx context.Context, + registration *AppRegistration, + credentials DeploymentAppCredentials, +) error { + if r == nil || r.store == nil || r.secrets == nil || registration == nil { + return errors.New("GitHub App registration repository is not configured") + } + if strings.TrimSpace(registration.ID) == "" || registration.CredentialGeneration != 1 { + return errors.New("new GitHub App registration is invalid") + } + if err := validateDeploymentAppCredentials(credentials); err != nil { + return err + } + r.store.deploymentAppPersistenceMu.Lock() + defer r.store.deploymentAppPersistenceMu.Unlock() + existing, err := r.store.GetAppRegistration(ctx, registration.ID) + if err != nil { + return err + } + if existing != nil { + return errors.New("GitHub App registration already exists") + } + secretID := appRegistrationCredentialSecretID(registration.ID, registration.CredentialGeneration) + bundle := DeploymentAppCredentialBundle{ + Version: DeploymentAppCredentialBundleVersion, Generation: registration.CredentialGeneration, + Credentials: credentials, + } + encoded, err := json.Marshal(bundle) + if err != nil { + return fmt.Errorf("encode GitHub App credential bundle: %w", err) + } + if err := r.secrets.Set(ctx, secretID, appRegistrationCredentialsSecretName, string(encoded)); err != nil { + return fmt.Errorf("store GitHub App credential bundle: %w", err) + } + next := *registration + next.CredentialSecretID = secretID + if err := r.store.InsertAppRegistration(ctx, &next); err != nil { + _ = r.secrets.Delete(context.WithoutCancel(ctx), secretID) + return err + } + *registration = next + return nil +} + +func (r *DeploymentAppRepository) saveRegistrationLocked( + ctx context.Context, + previous *AppRegistration, + registration *AppRegistration, + credentials DeploymentAppCredentials, +) error { + secretID := appRegistrationCredentialSecretID(registration.ID, registration.CredentialGeneration) + bundle := DeploymentAppCredentialBundle{ + Version: DeploymentAppCredentialBundleVersion, Generation: registration.CredentialGeneration, + Credentials: credentials, + } + encoded, err := json.Marshal(bundle) + if err != nil { + return fmt.Errorf("encode GitHub App credential bundle: %w", err) + } + if err := r.secrets.Set(ctx, secretID, appRegistrationCredentialsSecretName, string(encoded)); err != nil { + return fmt.Errorf("store GitHub App credential bundle: %w", err) + } + next := *registration + next.CredentialSecretID = secretID + if err := r.store.UpsertDeploymentAppRegistration(ctx, &next); err != nil { + _ = r.secrets.Delete(context.WithoutCancel(ctx), secretID) + return err + } + *registration = next + if previous != nil && previous.CredentialSecretID != "" && previous.CredentialSecretID != secretID { + _ = r.secrets.Delete(context.WithoutCancel(ctx), previous.CredentialSecretID) + } + return nil +} + +func (r *DeploymentAppRepository) LoadRegistration( + ctx context.Context, + registrationID string, +) (*AppRegistration, DeploymentAppCredentials, error) { + if r == nil || r.store == nil || r.secrets == nil { + return nil, DeploymentAppCredentials{}, errors.New("GitHub App registration repository is not configured") + } + registration, err := r.store.GetAppRegistration(ctx, registrationID) + if err != nil || registration == nil { + return registration, DeploymentAppCredentials{}, err + } + if registration.CredentialSecretID == "" { + return registration, DeploymentAppCredentials{}, errors.New("GitHub App credential pointer is missing") + } + encoded, err := r.secrets.Reveal(ctx, registration.CredentialSecretID) + if err != nil { + return registration, DeploymentAppCredentials{}, fmt.Errorf("load GitHub App credential bundle: %w", err) + } + bundle, err := decodeDeploymentAppCredentialBundle(encoded) + if err != nil { + return registration, DeploymentAppCredentials{}, err + } + if bundle.Generation != registration.CredentialGeneration { + return registration, DeploymentAppCredentials{}, errors.New("GitHub App credential generation mismatch") + } + return registration, bundle.Credentials, nil +} + +func (r *DeploymentAppRepository) DeleteRegistration(ctx context.Context, registrationID string) error { + if r == nil || r.store == nil || r.secrets == nil { + return errors.New("GitHub App registration repository is not configured") + } + r.store.deploymentAppPersistenceMu.Lock() + defer r.store.deploymentAppPersistenceMu.Unlock() + references, err := r.store.CountAppRegistrationBindings(ctx, registrationID) + if err != nil { + return err + } + if references > 0 { + return ErrDeploymentAppInUse + } + registration, err := r.store.GetAppRegistration(ctx, registrationID) + if err != nil { + return err + } + if registration == nil { + return r.store.DeleteAppRegistrationData(ctx, registrationID) + } + cleanupCtx := context.WithoutCancel(ctx) + encoded, err := r.secrets.Reveal(cleanupCtx, registration.CredentialSecretID) + if err != nil { + return errors.Join( + ErrAppRegistrationCredentialCleanup, + r.invalidateRegistrationAfterCredentialFailure(cleanupCtx, registration.ID), + ) + } + if err := r.secrets.Delete(cleanupCtx, registration.CredentialSecretID); err != nil { + if restoreErr := r.ensureRegistrationCredential( + cleanupCtx, registration.CredentialSecretID, encoded, + ); restoreErr != nil { + return errors.Join( + ErrAppRegistrationCredentialCleanup, + r.invalidateRegistrationAfterCredentialFailure(cleanupCtx, registration.ID), + ) + } + return ErrAppRegistrationCredentialCleanup + } + if err := r.store.DeleteAppRegistrationData(ctx, registrationID); err != nil { + if restoreErr := r.secrets.Set( + cleanupCtx, registration.CredentialSecretID, + appRegistrationCredentialsSecretName, encoded, + ); restoreErr != nil { + return errors.Join( + ErrAppRegistrationDeletionFailed, + ErrAppRegistrationCredentialCleanup, + r.invalidateRegistrationAfterCredentialFailure(cleanupCtx, registration.ID), + ) + } + return ErrAppRegistrationDeletionFailed + } + return nil +} + +func (r *DeploymentAppRepository) ensureRegistrationCredential( + ctx context.Context, + secretID, encoded string, +) error { + exists, err := r.secrets.Exists(ctx, secretID) + if err == nil && exists { + return nil + } + return r.secrets.Set(ctx, secretID, appRegistrationCredentialsSecretName, encoded) +} + +func (r *DeploymentAppRepository) invalidateRegistrationAfterCredentialFailure( + ctx context.Context, + registrationID string, +) error { + if err := r.store.MarkAppRegistrationInvalid( + ctx, registrationID, "GitHub App credentials are unavailable after a failed cleanup", + ); err != nil { + return ErrAppRegistrationDeletionFailed + } + return nil +} + +func (r *DeploymentAppRepository) CleanupOrphanedCredentialBundles(ctx context.Context) error { + if r == nil || r.store == nil || r.secrets == nil { + return errors.New("GitHub App registration repository is not configured") + } + r.store.deploymentAppPersistenceMu.Lock() + defer r.store.deploymentAppPersistenceMu.Unlock() + registrations, err := r.store.ListAppRegistrations(ctx) + if err != nil { + return err + } + activeIDs := make(map[string]struct{}, len(registrations)) + for _, registration := range registrations { + activeIDs[registration.CredentialSecretID] = struct{}{} + } + ids, err := r.secrets.ListIDs(ctx) + if err != nil { + return err + } + var cleanupErr error + for _, id := range ids { + if !isDeploymentAppCredentialSecretID(id) { + continue + } + if _, active := activeIDs[id]; active { + continue + } + if err := r.secrets.Delete(ctx, id); err != nil { + cleanupErr = errors.Join(cleanupErr, err) + } + } + return cleanupErr +} + +func appRegistrationCredentialSecretID(registrationID string, generation int64) string { + return fmt.Sprintf("%s%s:g%d:%s", AppRegistrationCredentialsSecretPrefix, + registrationID, generation, uuid.NewString()) +} + +func isDeploymentAppCredentialSecretID(id string) bool { + return strings.HasPrefix(id, AppRegistrationCredentialsSecretPrefix) +} + +func decodeDeploymentAppCredentialBundle(encoded string) (DeploymentAppCredentialBundle, error) { + var bundle DeploymentAppCredentialBundle + if err := json.Unmarshal([]byte(encoded), &bundle); err != nil { + return bundle, errors.New("GitHub App credential bundle is invalid") + } + if bundle.Version != DeploymentAppCredentialBundleVersion || bundle.Generation < 1 { + return bundle, errors.New("GitHub App credential bundle version is unsupported") + } + if err := validateDeploymentAppCredentials(bundle.Credentials); err != nil { + return bundle, err + } + return bundle, nil +} + +func validateDeploymentAppCredentials(credentials DeploymentAppCredentials) error { + if strings.TrimSpace(credentials.PrivateKey) == "" || credentials.ClientSecret == "" || + credentials.WebhookSecret == "" { + return errors.New("GitHub App credentials are incomplete") + } + return nil +} + +func (s *Store) CreateDeploymentAppRegistrationFlow( + ctx context.Context, + flow *DeploymentAppRegistrationFlow, +) error { + if flow == nil || flow.WorkspaceID == "" || flow.RegistrationID == "" { + return fmt.Errorf("workspace-bound GitHub App registration flow is required") + } + if flow.UserID == "" { + flow.UserID = flow.OperatorUserID + } + if flow.CreatedAt.IsZero() { + flow.CreatedAt = time.Now().UTC() + } + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, tx.Rebind(` + UPDATE github_app_registration_flows SET consumed_at = ? + WHERE workspace_id = ? AND consumed_at IS NULL`), flow.CreatedAt, flow.WorkspaceID); err != nil { + return err + } + _, err = tx.ExecContext(ctx, tx.Rebind(` + INSERT INTO github_app_registration_flows ( + state_hash, registration_id, workspace_id, user_id, owner_type, owner_login, + display_name, visibility, public_base_url, manifest_revision, expires_at, consumed_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), + flow.StateHash, flow.RegistrationID, flow.WorkspaceID, flow.UserID, flow.OwnerType, + flow.OwnerLogin, flow.DisplayName, flow.Visibility, flow.PublicBaseURL, + flow.ManifestRevision, flow.ExpiresAt, flow.ConsumedAt, flow.CreatedAt) + if err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) GetDeploymentAppRegistrationFlow( + ctx context.Context, + stateHash string, +) (*DeploymentAppRegistrationFlow, error) { + var flow DeploymentAppRegistrationFlow + err := s.ro.GetContext(ctx, &flow, s.ro.Rebind(` + SELECT state_hash, registration_id, workspace_id, user_id, owner_type, owner_login, + display_name, visibility, public_base_url, manifest_revision, expires_at, consumed_at, created_at + FROM github_app_registration_flows WHERE state_hash = ?`), stateHash) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + flow.OperatorUserID = flow.UserID + return &flow, err +} + +func (s *Store) ConsumeDeploymentAppRegistrationFlow( + ctx context.Context, + stateHash, registrationID string, + now time.Time, +) (*DeploymentAppRegistrationFlow, error) { + result, err := s.db.ExecContext(ctx, s.db.Rebind(` + UPDATE github_app_registration_flows SET consumed_at = ? + WHERE state_hash = ? AND registration_id = ? AND consumed_at IS NULL AND expires_at > ?`), + now, stateHash, registrationID, now) + if err != nil { + return nil, err + } + count, err := result.RowsAffected() + if err != nil { + return nil, err + } + if count != 1 { + return nil, ErrDeploymentAppFlowUnavailable + } + return s.GetDeploymentAppRegistrationFlow(ctx, stateHash) +} + +func (s *Store) IsLatestDeploymentAppRegistrationFlow(ctx context.Context, stateHash string) (bool, error) { + var count int + err := s.ro.GetContext(ctx, &count, s.ro.Rebind(` + SELECT COUNT(*) FROM github_app_registration_flows candidate + WHERE candidate.state_hash = ? AND NOT EXISTS ( + SELECT 1 FROM github_app_registration_flows newer + WHERE newer.workspace_id = candidate.workspace_id AND newer.created_at > candidate.created_at + )`), stateHash) + return count == 1, err +} + +func (s *Store) DeleteDeploymentAppRegistrationFlows(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM github_app_registration_flows`) + return err +} + +func (s *Store) DeleteAppRegistrationFlowsByWorkspace(ctx context.Context, workspaceID string) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind( + `DELETE FROM github_app_registration_flows WHERE workspace_id = ?`), workspaceID) + return err +} diff --git a/apps/backend/internal/github/gh_accounts.go b/apps/backend/internal/github/gh_accounts.go new file mode 100644 index 0000000000..6f08df29ab --- /dev/null +++ b/apps/backend/internal/github/gh_accounts.go @@ -0,0 +1,138 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/kandev/kandev/internal/common/subproc" +) + +const defaultGitHubHost = "github.com" + +// GHAccount is one login stored by the host gh CLI. It contains no token. +type GHAccount struct { + Host string `json:"host"` + Login string `json:"login"` + Active bool `json:"active"` + State string `json:"state"` + Selected bool `json:"selected"` +} + +type ghAccountCommandRunner interface { + Run(ctx context.Context, args ...string) (string, error) +} + +type systemGHAccountRunner struct{} + +func (systemGHAccountRunner) Run(ctx context.Context, args ...string) (string, error) { + var stdout, stderr bytes.Buffer + runErr, execCtxErr := subproc.RunGHAfterAcquire(ctx, resolveGHExecTimeout(ctx), func(execCtx context.Context) *exec.Cmd { + cmd := exec.CommandContext(execCtx, "gh", args...) + cmd.Env = githubCLIEnvironment(os.Environ()) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + return cmd + }) + if runErr == nil { + return stdout.String(), nil + } + if execCtxErr != nil && (errors.Is(execCtxErr, context.Canceled) || errors.Is(execCtxErr, context.DeadlineExceeded)) { + return "", execCtxErr + } + // Authentication commands may print tokens or credential locations. Keep + // diagnostics account-specific without reflecting subprocess output. + return "", fmt.Errorf("gh %s failed: %w", firstArg(args), runErr) +} + +func githubCLIEnvironment(source []string) []string { + filtered := make([]string, 0, len(source)) + for _, item := range source { + key, _, ok := strings.Cut(item, "=") + if ok && (strings.EqualFold(key, "GH_TOKEN") || strings.EqualFold(key, "GITHUB_TOKEN")) { + continue + } + filtered = append(filtered, item) + } + return filtered +} + +type ghAuthStatus struct { + Hosts map[string][]struct { + Active bool `json:"active"` + Host string `json:"host"` + Login string `json:"login"` + State string `json:"state"` + } `json:"hosts"` +} + +// ListGHAccounts returns every account known to gh without changing its +// active account. Ambient token variables are stripped so they cannot hide +// the accounts stored in gh's config. +func ListGHAccounts(ctx context.Context) ([]GHAccount, error) { + return listGHAccounts(ctx, systemGHAccountRunner{}) +} + +func listGHAccounts(ctx context.Context, runner ghAccountCommandRunner) ([]GHAccount, error) { + raw, err := runner.Run(ctx, "auth", "status", "--json", "hosts") + if err != nil { + return nil, fmt.Errorf("list gh accounts: %w", err) + } + var status ghAuthStatus + if err := json.Unmarshal([]byte(raw), &status); err != nil { + return nil, fmt.Errorf("decode gh accounts: %w", err) + } + accounts := make([]GHAccount, 0) + for host, entries := range status.Hosts { + for _, entry := range entries { + accountHost := entry.Host + if accountHost == "" { + accountHost = host + } + if accountHost == "" || entry.Login == "" { + continue + } + accounts = append(accounts, GHAccount{ + Host: accountHost, + Login: entry.Login, + Active: entry.Active, + State: entry.State, + }) + } + } + return accounts, nil +} + +// ResolveGHAccountToken returns the token for an exact host/login pair. It +// never changes gh's active account and never persists the returned token. +func ResolveGHAccountToken(ctx context.Context, host, login string) (string, error) { + return resolveGHAccountToken(ctx, systemGHAccountRunner{}, host, login) +} + +func resolveGHAccountToken(ctx context.Context, runner ghAccountCommandRunner, host, login string) (string, error) { + host = strings.TrimSpace(host) + login = strings.TrimSpace(login) + if host == "" { + host = defaultGitHubHost + } + if host != defaultGitHubHost { + return "", fmt.Errorf("unsupported GitHub host %q", host) + } + if login == "" { + return "", fmt.Errorf("GitHub login is required") + } + raw, err := runner.Run(ctx, "auth", "token", "--hostname", host, "--user", login) + if err != nil { + return "", fmt.Errorf("resolve gh account %s@%s: %w", login, host, err) + } + token := strings.TrimSpace(raw) + if token == "" { + return "", fmt.Errorf("resolve gh account %s@%s: empty token", login, host) + } + return token, nil +} diff --git a/apps/backend/internal/github/gh_accounts_test.go b/apps/backend/internal/github/gh_accounts_test.go new file mode 100644 index 0000000000..bf6f22e62c --- /dev/null +++ b/apps/backend/internal/github/gh_accounts_test.go @@ -0,0 +1,80 @@ +package github + +import ( + "context" + "reflect" + "strings" + "testing" +) + +type fakeGHAccountRunner struct { + output string + err error + args [][]string +} + +func (r *fakeGHAccountRunner) Run(_ context.Context, args ...string) (string, error) { + r.args = append(r.args, append([]string(nil), args...)) + return r.output, r.err +} + +func TestListGHAccountsParsesAllStoredLogins(t *testing.T) { + runner := &fakeGHAccountRunner{output: `{"hosts":{"github.com":[{"active":true,"host":"github.com","login":"alice","state":"success"},{"active":false,"host":"github.com","login":"work-bot","state":"success"}]}}`} + + got, err := listGHAccounts(context.Background(), runner) + if err != nil { + t.Fatalf("listGHAccounts: %v", err) + } + want := []GHAccount{ + {Host: "github.com", Login: "alice", Active: true, State: "success"}, + {Host: "github.com", Login: "work-bot", State: "success"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("accounts = %#v, want %#v", got, want) + } + if wantArgs := []string{"auth", "status", "--json", "hosts"}; !reflect.DeepEqual(runner.args[0], wantArgs) { + t.Fatalf("args = %#v, want %#v", runner.args[0], wantArgs) + } +} + +func TestResolveGHAccountTokenSelectsExactAccount(t *testing.T) { + runner := &fakeGHAccountRunner{output: "secret-token\n"} + + token, err := resolveGHAccountToken(context.Background(), runner, "github.com", "work-bot") + if err != nil { + t.Fatalf("resolveGHAccountToken: %v", err) + } + if token != "secret-token" { + t.Fatalf("token = %q", token) + } + wantArgs := []string{"auth", "token", "--hostname", "github.com", "--user", "work-bot"} + if !reflect.DeepEqual(runner.args[0], wantArgs) { + t.Fatalf("args = %#v, want %#v", runner.args[0], wantArgs) + } +} + +func TestResolveGHAccountTokenRejectsUnsupportedHost(t *testing.T) { + runner := &fakeGHAccountRunner{output: "must-not-run"} + if _, err := resolveGHAccountToken(context.Background(), runner, "git.example.com", "alice"); err == nil { + t.Fatal("expected unsupported host error") + } + if len(runner.args) != 0 { + t.Fatalf("runner called with %#v", runner.args) + } +} + +func TestGitHubCLIEnvironmentStripsAmbientTokens(t *testing.T) { + got := githubCLIEnvironment([]string{ + "PATH=/bin", + "GH_TOKEN=gh-secret", + "github_token=github-secret", + "HOME=/tmp/home", + }) + want := []string{"PATH=/bin", "HOME=/tmp/home"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("environment = %#v, want %#v", got, want) + } + if strings.Contains(strings.Join(got, "\n"), "secret") { + t.Fatal("filtered environment contains token material") + } +} diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index 04b5df88b6..109a89a0ea 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -482,6 +482,16 @@ func (c *GHClient) ListAccessibleRepos(ctx context.Context, query string, limit return filterReposByQuery(repos, query), nil } +// HasRepositoryAccess probes the repository root through the selected gh +// account. It does not rely on the first page returned by /user/repos. +func (c *GHClient) HasRepositoryAccess(ctx context.Context, owner, repo string) (bool, error) { + out, err := c.run(ctx, "api", fmt.Sprintf("/repos/%s/%s", owner, repo), "--jq", ".full_name") + if err != nil { + return false, err + } + return strings.TrimSpace(out) != "", nil +} + // buildAccessibleReposGHArgs constructs the `gh api /user/repos?...` argv for // ListAccessibleRepos. Keeping it pure makes the endpoint/query construction // unit-testable without spawning gh. Callers must clamp `limit` via diff --git a/apps/backend/internal/github/handlers.go b/apps/backend/internal/github/handlers.go index 932eec074e..c2189a22f0 100644 --- a/apps/backend/internal/github/handlers.go +++ b/apps/backend/internal/github/handlers.go @@ -9,6 +9,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/repository/repoerrors" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -122,6 +123,9 @@ func wsWithField(field string, serviceFn func(ctx context.Context, val string) ( } result, err := serviceFn(ctx, val) if err != nil { + if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "workspace not found", nil) + } return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } return ws.NewResponse(msg.ID, msg.Action, result) @@ -141,6 +145,9 @@ func wsDeleteByID(deleteFn func(ctx context.Context, id string) error) func(ctx return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, errMsgIDRequired, nil) } if err := deleteFn(ctx, id); err != nil { + if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "watch not found", nil) + } return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } return ws.NewResponse(msg.ID, msg.Action, map[string]bool{respKeyDeleted: true}) @@ -165,6 +172,9 @@ func wsUpdateByPayload[T any](updateFn func(ctx context.Context, id string, req return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, errMsgInvalidPayload, nil) } if err := updateFn(ctx, idHolder.ID, &req); err != nil { + if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "watch not found", nil) + } return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"updated": true}) @@ -197,7 +207,12 @@ func wsTriggerAllByWorkspace( func wsStatus(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - status, err := svc.GetStatus(ctx) + payload, parseErr := parseMap(msg) + if parseErr != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, errMsgInvalidPayload, nil) + } + workspaceID, _ := payload["workspace_id"].(string) + status, err := svc.GetWorkspaceAuthStatus(ctx, workspaceID, githubUserID(ctx)) if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } @@ -286,12 +301,15 @@ func wsGetPRFeedback(svc *Service, _ *logger.Logger) func(ctx context.Context, m } owner, _ := payload["owner"].(string) repo, _ := payload["repo"].(string) + workspaceID, _ := payload["workspace_id"].(string) numberF, _ := payload["number"].(float64) number := int(numberF) if owner == "" || repo == "" || number == 0 { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "owner, repo, number required", nil) } - feedback, err := svc.GetPRFeedback(ctx, owner, repo, number) + feedback, err := svc.GetPRFeedbackForWorkspace( + ctx, workspaceID, githubUserID(ctx), owner, repo, number, + ) if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } @@ -312,7 +330,7 @@ func wsCreateReviewWatch(svc *Service, _ *logger.Logger) func(ctx context.Contex if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, errMsgInvalidPayload, nil) } - rw, err := svc.CreateReviewWatch(ctx, &req) + rw, err := svc.CreateReviewWatchForUser(ctx, githubUserID(ctx), &req) if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } @@ -368,13 +386,9 @@ func wsTriggerAllReviewChecks(svc *Service, _ *logger.Logger) func(ctx context.C } func wsListPRWatches(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - watches, err := svc.ListActivePRWatches(ctx) - if err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) - } - return ws.NewResponse(msg.ID, msg.Action, watches) - } + return wsWithField("workspace_id", func(ctx context.Context, workspaceID string) (interface{}, error) { + return svc.ListActivePRWatchesForWorkspace(ctx, workspaceID) + }) } func wsDeletePRWatch(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -420,7 +434,11 @@ func wsGetPRFiles(svc *Service, _ *logger.Logger) func(ctx context.Context, msg if errResp != nil { return errResp, nil } - files, err := svc.GetPRFiles(ctx, owner, repo, number) + payload, _ := parseMap(msg) + workspaceID, _ := payload["workspace_id"].(string) + files, err := svc.GetPRFilesForWorkspace( + ctx, workspaceID, githubUserID(ctx), owner, repo, number, + ) if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } @@ -434,7 +452,11 @@ func wsGetPRCommits(svc *Service, _ *logger.Logger) func(ctx context.Context, ms if errResp != nil { return errResp, nil } - commits, err := svc.GetPRCommits(ctx, owner, repo, number) + payload, _ := parseMap(msg) + workspaceID, _ := payload["workspace_id"].(string) + commits, err := svc.GetPRCommitsForWorkspace( + ctx, workspaceID, githubUserID(ctx), owner, repo, number, + ) if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } @@ -456,8 +478,11 @@ func wsCreateIssueWatch(svc *Service, _ *logger.Logger) func(ctx context.Context if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, errMsgInvalidPayload, nil) } - iw, err := svc.CreateIssueWatch(ctx, &req) + iw, err := svc.CreateIssueWatchForWorkspace(ctx, &req) if err != nil { + if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "issue watch not found", nil) + } return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } return ws.NewResponse(msg.ID, msg.Action, iw) @@ -490,6 +515,9 @@ func wsTriggerIssueWatch(svc *Service, log *logger.Logger) func(ctx context.Cont } watch, err := svc.GetIssueWatch(ctx, id) if err != nil { + if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "issue watch not found", nil) + } return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } if watch == nil || watch.WorkspaceID != workspaceID { diff --git a/apps/backend/internal/github/handlers_authorization_test.go b/apps/backend/internal/github/handlers_authorization_test.go new file mode 100644 index 0000000000..7e78fd51bd --- /dev/null +++ b/apps/backend/internal/github/handlers_authorization_test.go @@ -0,0 +1,83 @@ +package github + +import ( + "context" + "strings" + "testing" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestReviewWatchWebSocketDeniesForeignWorkspaceListAndMutations(t *testing.T) { + store := newTestStore(t) + watch := &ReviewWatch{WorkspaceID: "workspace-b", Prompt: "workspace-b review secret"} + if err := store.CreateReviewWatch(context.Background(), watch); err != nil { + t.Fatalf("create victim review watch: %v", err) + } + + svc := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + svc.SetWorkspaceAuthorizer(func(ctx context.Context, workspaceID string) error { + identity, ok := authn.IdentityFromContext(ctx) + if !ok || identity.UserID != "member-a" || identity.Role != authn.RoleMember { + t.Fatalf("authorizer identity = %#v, %v; want authenticated member-a", identity, ok) + } + if workspaceID != watch.WorkspaceID { + t.Fatalf("authorizer workspace = %q, want %q", workspaceID, watch.WorkspaceID) + } + return repoerrors.ErrWorkspaceNotFound + }) + + dispatcher := ws.NewDispatcher() + registerWSHandlers(dispatcher, svc, logger.Default()) + ctx := authn.WithIdentity(context.Background(), authn.Identity{UserID: "member-a", Role: authn.RoleMember}) + + for _, tc := range []struct { + name string + action string + payload map[string]any + }{ + { + name: "list", + action: ws.ActionGitHubReviewWatchesList, + payload: map[string]any{"workspace_id": watch.WorkspaceID}, + }, + { + name: "update", + action: ws.ActionGitHubReviewWatchUpdate, + payload: map[string]any{"id": watch.ID, "prompt": "attacker update"}, + }, + { + name: "delete", + action: ws.ActionGitHubReviewWatchDelete, + payload: map[string]any{"id": watch.ID}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + request, err := ws.NewRequest("request-"+tc.name, tc.action, tc.payload) + if err != nil { + t.Fatalf("create request: %v", err) + } + response, err := dispatcher.Dispatch(ctx, request) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if response.Type != ws.MessageTypeError || !strings.Contains(string(response.Payload), ws.ErrorCodeNotFound) { + t.Fatalf("response = %#v, want sanitized NOT_FOUND", response) + } + if strings.Contains(string(response.Payload), watch.ID) || strings.Contains(string(response.Payload), watch.Prompt) { + t.Fatalf("response = %s, must not disclose victim watch metadata", response.Payload) + } + }) + } + + remaining, err := store.GetReviewWatch(context.Background(), watch.ID) + if err != nil { + t.Fatalf("load victim review watch after denied mutations: %v", err) + } + if remaining == nil || remaining.Prompt != watch.Prompt { + t.Fatalf("victim review watch = %#v, want unchanged prompt %q", remaining, watch.Prompt) + } +} diff --git a/apps/backend/internal/github/mock_auth.go b/apps/backend/internal/github/mock_auth.go new file mode 100644 index 0000000000..01cff3bdef --- /dev/null +++ b/apps/backend/internal/github/mock_auth.go @@ -0,0 +1,192 @@ +package github + +import ( + "context" + "fmt" + "strings" + "sync" +) + +// MockAuthState supplies workspace-scoped principals while delegating API +// behavior to the existing shared MockClient data set. +type MockAuthState struct { + mu sync.RWMutex + client *MockClient + capabilities map[string]map[GitHubAppCapability]bool + cliAccounts []GHAccount +} + +func NewMockAuthState(client *MockClient) *MockAuthState { + return &MockAuthState{client: client, capabilities: make(map[string]map[GitHubAppCapability]bool)} +} + +func (m *MockAuthState) ResolveAutomation( + _ context.Context, + connection *WorkspaceConnection, + _ ResolveCredentialRequest, +) (*ResolvedCredential, error) { + if m == nil || m.client == nil || connection == nil { + return nil, ErrGitHubNotConfigured + } + login := connection.Login + kind := AuthPrincipalHuman + installationID := int64(0) + if connection.Source == ConnectionSourceGitHubAppInstallation { + kind = AuthPrincipalApp + login = connection.InstallationAccountLogin + if connection.InstallationID != nil { + installationID = *connection.InstallationID + } + } else if connection.Source == ConnectionSourceLegacyShared && login == "" { + login, _ = m.client.GetAuthenticatedUser(context.Background()) + } + return &ResolvedCredential{ + Client: &mockPrincipalClient{MockClient: m.client, login: login}, + Principal: AuthPrincipal{ + Kind: kind, Source: connection.Source, Login: login, InstallationID: installationID, + }, + Capabilities: m.workspaceCapabilities(connection.WorkspaceID), + RateTracker: NewRateTracker(nil, nil), + credential: "mock-token:" + connection.WorkspaceID, + }, nil +} + +func (m *MockAuthState) ResolveUser( + _ context.Context, + connection *UserConnection, + _ ResolveCredentialRequest, +) (*ResolvedCredential, error) { + if m == nil || m.client == nil || connection == nil { + return nil, ErrGitHubPersonalRequired + } + return &ResolvedCredential{ + Client: &mockPrincipalClient{MockClient: m.client, login: connection.Login}, + Principal: AuthPrincipal{ + Kind: AuthPrincipalHuman, Source: ConnectionSourceGitHubAppUser, + Login: connection.Login, UserID: connection.UserID, + }, + Capabilities: allTokenCapabilities(), + RateTracker: NewRateTracker(nil, nil), + credential: "mock-user-token:" + connection.WorkspaceID + ":" + connection.UserID, + }, nil +} + +func (m *MockAuthState) SetWorkspaceCapabilities( + workspaceID string, + capabilities map[GitHubAppCapability]bool, +) { + m.mu.Lock() + defer m.mu.Unlock() + m.capabilities[workspaceID] = cloneCapabilities(capabilities) +} + +func (m *MockAuthState) DeleteWorkspace(workspaceID string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.capabilities, workspaceID) +} + +func (m *MockAuthState) workspaceCapabilities(workspaceID string) map[GitHubAppCapability]bool { + m.mu.RLock() + capabilities, exists := m.capabilities[workspaceID] + m.mu.RUnlock() + if !exists { + return allTokenCapabilities() + } + return cloneCapabilities(capabilities) +} + +func (m *MockAuthState) SetCLIAccounts(accounts []GHAccount) { + m.mu.Lock() + defer m.mu.Unlock() + m.cliAccounts = append([]GHAccount(nil), accounts...) +} + +func (m *MockAuthState) ListCLIAccounts(context.Context) ([]GHAccount, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return append([]GHAccount(nil), m.cliAccounts...), nil +} + +func (m *MockAuthState) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.capabilities = make(map[string]map[GitHubAppCapability]bool) + m.cliAccounts = nil +} + +func cloneCapabilities(source map[GitHubAppCapability]bool) map[GitHubAppCapability]bool { + result := make(map[GitHubAppCapability]bool, len(source)) + for capability, allowed := range source { + result[capability] = allowed + } + return result +} + +// mockPrincipalClient overrides identity while sharing every seeded GitHub +// object with the installation-global MockClient. +type mockPrincipalClient struct { + *MockClient + login string +} + +func (c *mockPrincipalClient) IsAuthenticated(context.Context) (bool, error) { return true, nil } + +func (c *mockPrincipalClient) GetAuthenticatedUser(context.Context) (string, error) { + return c.login, nil +} + +func (s *Service) enableMockAuth(client *MockClient) *MockAuthState { + if s.mockAuth != nil { + return s.mockAuth + } + state := NewMockAuthState(client) + s.mockAuth = state + s.ghAccountLister = state.ListCLIAccounts + if s.resolver != nil { + s.resolver.SetAutomationProvider(state) + s.resolver.SetUserProvider(state) + s.resolver.ghToken = func(_ context.Context, host, login string) (string, error) { + if strings.TrimSpace(login) == "" { + return "", fmt.Errorf("GitHub login is required") + } + return "mock-gh:" + host + ":" + login, nil + } + } + s.tokenClientFactory = func(token string) Client { + login := strings.TrimSpace(strings.TrimPrefix(token, "mock-pat:")) + if strings.HasPrefix(token, "mock-gh:") { + parts := strings.Split(token, ":") + login = parts[len(parts)-1] + } + if login == "" || login == token { + login, _ = client.GetAuthenticatedUser(context.Background()) + } + return &mockPrincipalClient{MockClient: client, login: login} + } + return state +} + +// ResetMockAuth clears in-memory mock identity state for one workspace, or +// all workspaces when workspaceID is empty. +func (s *Service) ResetMockAuth(workspaceID string) { + if s == nil { + return + } + if s.mockAuth == nil { + return + } + if workspaceID == "" { + s.mockAuth.Reset() + } else { + s.mockAuth.DeleteWorkspace(workspaceID) + s.mockAuth.SetCLIAccounts(nil) + } + if s.resolver != nil { + if workspaceID == "" { + s.resolver.InvalidateAll() + } else { + s.resolver.InvalidateWorkspace(workspaceID) + } + } +} diff --git a/apps/backend/internal/github/mock_client.go b/apps/backend/internal/github/mock_client.go index e5d05d8e6c..8b158acf24 100644 --- a/apps/backend/internal/github/mock_client.go +++ b/apps/backend/internal/github/mock_client.go @@ -383,6 +383,24 @@ func (m *MockClient) ListAccessibleRepos(_ context.Context, query string, _ int) return filterReposByQuery(all, query), nil } +func (m *MockClient) HasRepositoryAccess(_ context.Context, owner, repo string) (bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.reposUnavailable { + return false, ErrNoClient + } + want := owner + "/" + repo + for _, repos := range m.repos { + for _, candidate := range repos { + if strings.EqualFold(candidate.FullName, want) || + (strings.EqualFold(candidate.Owner, owner) && strings.EqualFold(candidate.Name, repo)) { + return true, nil + } + } + } + return false, nil +} + func (m *MockClient) ListPRReviews(_ context.Context, owner, repo string, number int) ([]PRReview, error) { m.mu.RLock() defer m.mu.RUnlock() diff --git a/apps/backend/internal/github/mock_controller.go b/apps/backend/internal/github/mock_controller.go index f1c7e8cda4..48ed38f57b 100644 --- a/apps/backend/internal/github/mock_controller.go +++ b/apps/backend/internal/github/mock_controller.go @@ -2,8 +2,10 @@ package github import ( "context" + "errors" "fmt" "net/http" + "strings" "time" "github.com/gin-gonic/gin" @@ -19,6 +21,7 @@ const defaultPRState = "open" // MockController handles HTTP endpoints for controlling the MockClient in E2E tests. type MockController struct { mock *MockClient + auth *MockAuthState store *Store eventBus bus.EventBus logger *logger.Logger @@ -27,7 +30,11 @@ type MockController struct { // NewMockController creates a new MockController. func NewMockController(mock *MockClient, store *Store, eventBus bus.EventBus, svc *Service, log *logger.Logger) *MockController { - return &MockController{mock: mock, store: store, eventBus: eventBus, service: svc, logger: log} + auth := NewMockAuthState(mock) + if svc != nil { + auth = svc.enableMockAuth(mock) + } + return &MockController{mock: mock, auth: auth, store: store, eventBus: eventBus, service: svc, logger: log} } // RegisterRoutes registers all mock control HTTP routes. @@ -48,10 +55,276 @@ func (c *MockController) RegisterRoutes(router *gin.Engine) { api.POST("/task-prs", c.associateTaskPR) api.POST("/pr-feedback", c.seedPRFeedback) api.PUT("/auth-health", c.setAuthHealth) + api.PUT("/workspace-connections/:workspaceId", c.setWorkspaceConnection) + api.DELETE("/workspace-connections/:workspaceId", c.deleteWorkspaceConnection) + api.PUT("/workspace-connections/:workspaceId/status", c.setWorkspaceConnectionStatus) + api.PUT("/personal-connections/:workspaceId", c.setPersonalConnection) + api.DELETE("/personal-connections/:workspaceId", c.deletePersonalConnection) + api.PUT("/cli-accounts", c.setCLIAccounts) + api.PUT("/app-registrations/:registrationId", c.setAppRegistration) api.PUT("/repos-unavailable", c.setReposUnavailable) api.DELETE("/reset", c.reset) } +type mockWorkspaceConnectionRequest struct { + Source ConnectionSource `json:"source"` + Status ConnectionStatus `json:"status"` + Login string `json:"login"` + InstallationID *int64 `json:"installation_id"` + InstallationAccountLogin string `json:"installation_account_login"` + InstallationAccountType string `json:"installation_account_type"` + AppRegistrationID string `json:"app_registration_id"` + Capabilities map[GitHubAppCapability]bool `json:"capabilities"` +} + +func (c *MockController) setWorkspaceConnection(ctx *gin.Context) { + if c.store == nil || c.service == nil || c.auth == nil { + ctx.JSON(http.StatusConflict, gin.H{errKey: "mock GitHub auth is unavailable"}) + return + } + workspaceID := strings.TrimSpace(ctx.Param("workspaceId")) + var req mockWorkspaceConnectionRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + respondInvalidPayload(ctx) + return + } + if req.Status == "" { + req.Status = ConnectionStatusActive + } + if err := validateMockWorkspaceConnectionRequest(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{errKey: err.Error()}) + return + } + existing, err := c.store.GetWorkspaceConnection(ctx.Request.Context(), workspaceID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + connection := &WorkspaceConnection{ + WorkspaceID: workspaceID, Source: req.Source, GitHubHost: defaultGitHubHost, + Login: req.Login, InstallationID: req.InstallationID, + InstallationAccountLogin: req.InstallationAccountLogin, + InstallationAccountType: req.InstallationAccountType, + AppRegistrationID: req.AppRegistrationID, + Status: req.Status, CredentialGeneration: nextCredentialGeneration(existing), + } + if err := c.store.UpsertWorkspaceConnection(ctx.Request.Context(), connection); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + if req.Capabilities == nil { + c.auth.DeleteWorkspace(workspaceID) + } else { + c.auth.SetWorkspaceCapabilities(workspaceID, req.Capabilities) + } + c.service.invalidateWorkspaceCredential(workspaceID) + ctx.JSON(http.StatusOK, gin.H{"connection": connection, "capabilities": c.auth.workspaceCapabilities(workspaceID)}) +} + +func validateMockWorkspaceConnectionRequest(req *mockWorkspaceConnectionRequest) error { + if req == nil || !validMockConnectionSource(req.Source) || !validMockConnectionStatus(req.Status) { + return errors.New(errMsgInvalidPayload) + } + switch req.Source { + case ConnectionSourceGitHubAppInstallation: + if req.InstallationID == nil || *req.InstallationID <= 0 { + return errors.New("installation_id is required for GitHub App connections") + } + if strings.TrimSpace(req.InstallationAccountLogin) == "" { + return errors.New("installation_account_login is required for GitHub App connections") + } + if strings.TrimSpace(req.AppRegistrationID) == "" { + return errors.New("app_registration_id is required for GitHub App connections") + } + if req.InstallationAccountType == "" { + req.InstallationAccountType = string(DeploymentAppOwnerOrganization) + } + case ConnectionSourcePAT, ConnectionSourceGHCLI: + if strings.TrimSpace(req.Login) == "" { + return errors.New("login is required for PAT and CLI connections") + } + } + return nil +} + +func validMockConnectionSource(source ConnectionSource) bool { + switch source { + case ConnectionSourcePAT, ConnectionSourceGHCLI, ConnectionSourceGitHubAppInstallation, + ConnectionSourceLegacyShared: + return true + default: + return false + } +} + +func validMockConnectionStatus(status ConnectionStatus) bool { + switch status { + case "", ConnectionStatusActive, ConnectionStatusInvalid, ConnectionStatusSuspended, ConnectionStatusRevoked: + return true + default: + return false + } +} + +func (c *MockController) deleteWorkspaceConnection(ctx *gin.Context) { + workspaceID := strings.TrimSpace(ctx.Param("workspaceId")) + if c.service == nil { + ctx.JSON(http.StatusConflict, gin.H{errKey: "mock GitHub auth is unavailable"}) + return + } + if err := c.service.DeleteWorkspaceConnection(ctx.Request.Context(), workspaceID); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + c.auth.DeleteWorkspace(workspaceID) + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *MockController) setWorkspaceConnectionStatus(ctx *gin.Context) { + var req struct { + Status ConnectionStatus `json:"status"` + } + if err := ctx.ShouldBindJSON(&req); err != nil || req.Status == "" || !validMockConnectionStatus(req.Status) { + respondInvalidPayload(ctx) + return + } + workspaceID := strings.TrimSpace(ctx.Param("workspaceId")) + connection, err := c.store.GetWorkspaceConnection(ctx.Request.Context(), workspaceID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + if connection == nil { + ctx.JSON(http.StatusNotFound, gin.H{errKey: "workspace connection not found"}) + return + } + connection.Status = req.Status + connection.CredentialGeneration++ + if err := c.store.UpsertWorkspaceConnection(ctx.Request.Context(), connection); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + c.service.invalidateWorkspaceCredential(workspaceID) + ctx.JSON(http.StatusOK, connection) +} + +func (c *MockController) setPersonalConnection(ctx *gin.Context) { + var req struct { + Login string `json:"login"` + Status ConnectionStatus `json:"status"` + GitHubUserID int64 `json:"github_user_id"` + AccessExpiresAt time.Time `json:"access_expires_at"` + } + if err := ctx.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Login) == "" || + req.GitHubUserID <= 0 || req.AccessExpiresAt.IsZero() || + (req.Status != ConnectionStatusActive && req.Status != ConnectionStatusInvalid && req.Status != ConnectionStatusRevoked) { + respondInvalidPayload(ctx) + return + } + workspaceID := strings.TrimSpace(ctx.Param("workspaceId")) + workspace, err := c.store.GetWorkspaceConnection(ctx.Request.Context(), workspaceID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + if workspace == nil || workspace.Source != ConnectionSourceGitHubAppInstallation || + strings.TrimSpace(workspace.AppRegistrationID) == "" { + ctx.JSON(http.StatusBadRequest, gin.H{errKey: "workspace GitHub App connection is required"}) + return + } + existing, err := c.store.GetUserConnection(ctx.Request.Context(), workspaceID, DefaultUserID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + generation := int64(1) + if existing != nil { + generation = existing.CredentialGeneration + 1 + } + connection := &UserConnection{ + WorkspaceID: workspaceID, UserID: DefaultUserID, AppRegistrationID: workspace.AppRegistrationID, + Login: req.Login, + GitHubUserID: req.GitHubUserID, Status: req.Status, AccessExpiresAt: req.AccessExpiresAt, + CredentialGeneration: generation, + } + if err := c.store.UpsertUserConnection(ctx.Request.Context(), connection); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + c.service.invalidateWorkspaceCredential(workspaceID) + ctx.JSON(http.StatusOK, connection) +} + +func (c *MockController) deletePersonalConnection(ctx *gin.Context) { + workspaceID := strings.TrimSpace(ctx.Param("workspaceId")) + if err := c.store.DeleteUserConnection(ctx.Request.Context(), workspaceID, DefaultUserID); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + c.service.invalidateWorkspaceCredential(workspaceID) + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *MockController) setCLIAccounts(ctx *gin.Context) { + var req struct { + Accounts []GHAccount `json:"accounts"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + respondInvalidPayload(ctx) + return + } + for _, account := range req.Accounts { + if strings.TrimSpace(account.Host) == "" || strings.TrimSpace(account.Login) == "" { + respondInvalidPayload(ctx) + return + } + } + c.auth.SetCLIAccounts(req.Accounts) + ctx.JSON(http.StatusOK, gin.H{"accounts": req.Accounts}) +} + +func (c *MockController) setAppRegistration(ctx *gin.Context) { + var req struct { + DisplayName string `json:"display_name"` + AppID int64 `json:"app_id"` + } + registrationID := strings.TrimSpace(ctx.Param("registrationId")) + if err := ctx.ShouldBindJSON(&req); err != nil || registrationID == "" || + strings.TrimSpace(req.DisplayName) == "" || req.AppID <= 0 { + respondInvalidPayload(ctx) + return + } + if c.store == nil || c.service == nil || c.service.connectionSecrets == nil { + ctx.JSON(http.StatusConflict, gin.H{errKey: "mock GitHub App storage is unavailable"}) + return + } + now := time.Now().UTC() + registration := &AppRegistration{ + ID: registrationID, Source: AppRegistrationSourceImported, DisplayName: req.DisplayName, + GitHubHost: defaultGitHubAppHost, AppID: req.AppID, ClientID: "mock-client-" + registrationID, + Slug: "mock-" + registrationID, OwnerLogin: "mock-owner", + OwnerType: AppRegistrationOwnerOrganization, Visibility: AppRegistrationVisibilityPrivate, + PublicBaseURL: "https://kandev.example", CredentialGeneration: 1, + Status: AppRegistrationStatusActive, WebhookStatus: DeploymentAppWebhookUnverified, + CreatedAt: now, UpdatedAt: now, + } + repository := NewAppRegistrationRepository(c.store, c.service.connectionSecrets) + if err := repository.SaveRegistration(ctx.Request.Context(), registration, DeploymentAppCredentials{ + PrivateKey: "mock-private-key", ClientSecret: "mock-client-secret", + WebhookSecret: "mock-webhook-secret", + }); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + c.service.mu.Lock() + c.service.appRegistrationRuntimes[registrationID] = &githubAppRuntime{ + registrationID: registrationID, source: DeploymentAppSourceManaged, + generation: 1, appID: req.AppID, + } + c.service.mu.Unlock() + ctx.JSON(http.StatusOK, registration) +} + // setReposUnavailable toggles the mock client's "list accessible repos // unavailable" branch so e2e tests can drive the Remote-tab chip popover // into its "Connect GitHub" banner state. Also clears the service's @@ -474,6 +747,19 @@ func (c *MockController) reset(ctx *gin.Context) { c.mock.Reset() if c.service != nil { c.service.ClearAccessibleReposCaches() + c.service.ResetMockAuth("") + } + if c.store != nil { + if err := c.store.DeleteAllMockAuthData(ctx.Request.Context()); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + } + if c.service != nil { + if err := c.service.ResetAppRegistrationsForE2E(ctx.Request.Context(), ""); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } } ctx.JSON(http.StatusOK, gin.H{"reset": true}) } diff --git a/apps/backend/internal/github/mock_controller_test.go b/apps/backend/internal/github/mock_controller_test.go index 14e56b3060..863036c2c5 100644 --- a/apps/backend/internal/github/mock_controller_test.go +++ b/apps/backend/internal/github/mock_controller_test.go @@ -12,6 +12,189 @@ import ( "github.com/gin-gonic/gin" ) +func setupWorkspaceAuthMockController(t *testing.T) (*gin.Engine, *Service, *Store) { + t.Helper() + gin.SetMode(gin.TestMode) + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1", "workspace-2") + mock := NewMockClient() + svc := NewService(mock, AuthMethodPAT, nil, store, nil, newControllerTestLogger()) + svc.SetConnectionSecretStore(&fakeConnectionSecrets{values: make(map[string]string)}) + router := gin.New() + NewController(svc, newControllerTestLogger()).RegisterHTTPRoutes(router) + RegisterMockRoutes(router, svc, newControllerTestLogger()) + return router, svc, store +} + +func serveMockJSON(t *testing.T, router *gin.Engine, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + return response +} + +func TestMockControllerWorkspaceConnectionsResolveIsolatedPrincipals(t *testing.T) { + router, svc, _ := setupWorkspaceAuthMockController(t) + registration := serveMockJSON(t, router, http.MethodPut, + "/api/v1/github/mock/app-registrations/registration-work", + `{"display_name":"Work App","app_id":101}`) + if registration.Code != http.StatusOK { + t.Fatalf("seed App registration: %d %s", registration.Code, registration.Body.String()) + } + first := serveMockJSON(t, router, http.MethodPut, + "/api/v1/github/mock/workspace-connections/workspace-1", + `{"source":"github_app_installation","status":"active","app_registration_id":"registration-work","installation_id":42,"installation_account_login":"acme","installation_account_type":"Organization","capabilities":{"pull_request_read":true}}`) + if first.Code != http.StatusOK { + t.Fatalf("seed app connection: %d %s", first.Code, first.Body.String()) + } + second := serveMockJSON(t, router, http.MethodPut, + "/api/v1/github/mock/workspace-connections/workspace-2", + `{"source":"gh_cli","status":"active","login":"alice","capabilities":{"pull_request_write":true}}`) + if second.Code != http.StatusOK { + t.Fatalf("seed CLI connection: %d %s", second.Code, second.Body.String()) + } + + app, err := svc.GetWorkspaceAuthStatus(context.Background(), "workspace-1", DefaultUserID) + if err != nil || app.Automation == nil || app.Automation.Actor == nil { + t.Fatalf("app status = %+v, err = %v", app, err) + } + if app.Automation.Actor.Kind != AuthPrincipalApp || app.Automation.Actor.Login != "acme" || + !app.Automation.Capabilities[CapabilityPullRequestRead] || app.Automation.Capabilities[CapabilityPullRequestWrite] { + t.Fatalf("app automation = %+v", app.Automation) + } + cli, err := svc.GetWorkspaceAuthStatus(context.Background(), "workspace-2", DefaultUserID) + if err != nil || cli.Automation == nil || cli.Automation.Actor == nil || + cli.Automation.Actor.Kind != AuthPrincipalHuman || cli.Automation.Actor.Login != "alice" { + t.Fatalf("CLI status = %+v, err = %v", cli, err) + } +} + +func TestMockControllerPersonalCLIAvailabilityTransitionsAndReset(t *testing.T) { + router, svc, store := setupWorkspaceAuthMockController(t) + registration := serveMockJSON(t, router, http.MethodPut, + "/api/v1/github/mock/app-registrations/registration-work", + `{"display_name":"Work App","app_id":101}`) + if registration.Code != http.StatusOK { + t.Fatalf("seed App registration: %d %s", registration.Code, registration.Body.String()) + } + seed := serveMockJSON(t, router, http.MethodPut, + "/api/v1/github/mock/workspace-connections/workspace-1", + `{"source":"github_app_installation","status":"active","app_registration_id":"registration-work","installation_id":42,"installation_account_login":"acme"}`) + if seed.Code != http.StatusOK { + t.Fatalf("seed workspace: %d %s", seed.Code, seed.Body.String()) + } + personal := serveMockJSON(t, router, http.MethodPut, + "/api/v1/github/mock/personal-connections/workspace-1", + `{"login":"bob","status":"active","github_user_id":7,"access_expires_at":"2030-01-01T00:00:00Z"}`) + if personal.Code != http.StatusOK { + t.Fatalf("seed personal connection: %d %s", personal.Code, personal.Body.String()) + } + accounts := serveMockJSON(t, router, http.MethodPut, "/api/v1/github/mock/cli-accounts", + `{"accounts":[{"host":"github.com","login":"alice","active":true,"state":"success"}]}`) + if accounts.Code != http.StatusOK { + t.Fatalf("seed CLI accounts: %d %s", accounts.Code, accounts.Body.String()) + } + status, err := svc.GetWorkspaceAuthStatus(context.Background(), "workspace-1", DefaultUserID) + if err != nil || status.EffectivePersonalActor == nil || status.EffectivePersonalActor.Login != "bob" || + !status.GitHubAppAvailable { + t.Fatalf("workspace status = %+v, err = %v", status, err) + } + listed := serveMockJSON(t, router, http.MethodGet, "/api/v1/github/auth/gh-cli/accounts", "") + if listed.Code != http.StatusOK || !bytes.Contains(listed.Body.Bytes(), []byte(`"login":"alice"`)) { + t.Fatalf("list CLI accounts: %d %s", listed.Code, listed.Body.String()) + } + pat := serveMockJSON(t, router, http.MethodPut, + "/api/v1/github/workspace-connection?workspace_id=workspace-2", + `{"source":"pat","token":"mock-pat:carol"}`) + if pat.Code != http.StatusOK { + t.Fatalf("configure mock PAT: %d %s", pat.Code, pat.Body.String()) + } + patStatus, err := svc.GetWorkspaceAuthStatus(context.Background(), "workspace-2", DefaultUserID) + if err != nil || patStatus.Automation == nil || patStatus.Automation.Actor == nil || + patStatus.Automation.Actor.Login != "carol" { + t.Fatalf("configured PAT status = %+v, err = %v", patStatus, err) + } + configured := serveMockJSON(t, router, http.MethodPut, + "/api/v1/github/workspace-connection?workspace_id=workspace-2", + `{"source":"gh_cli","host":"github.com","login":"alice"}`) + if configured.Code != http.StatusOK { + t.Fatalf("configure injected CLI account: %d %s", configured.Code, configured.Body.String()) + } + cliStatus, err := svc.GetWorkspaceAuthStatus(context.Background(), "workspace-2", DefaultUserID) + if err != nil || cliStatus.Automation == nil || cliStatus.Automation.Actor == nil || + cliStatus.Automation.Actor.Login != "alice" { + t.Fatalf("configured CLI status = %+v, err = %v", cliStatus, err) + } + transition := serveMockJSON(t, router, http.MethodPut, + "/api/v1/github/mock/workspace-connections/workspace-1/status", `{"status":"suspended"}`) + if transition.Code != http.StatusOK { + t.Fatalf("transition connection: %d %s", transition.Code, transition.Body.String()) + } + status, err = svc.GetWorkspaceAuthStatus(context.Background(), "workspace-1", DefaultUserID) + if err != nil || status.Authenticated || status.Automation.Status != ConnectionStatusSuspended { + t.Fatalf("suspended status = %+v, err = %v", status, err) + } + + reset := serveMockJSON(t, router, http.MethodDelete, "/api/v1/github/mock/reset", "") + if reset.Code != http.StatusOK { + t.Fatalf("reset: %d %s", reset.Code, reset.Body.String()) + } + connection, err := store.GetWorkspaceConnection(context.Background(), "workspace-1") + if err != nil || connection != nil { + t.Fatalf("connection after reset = %+v, err = %v", connection, err) + } + status, err = svc.GetWorkspaceAuthStatus(context.Background(), "workspace-1", DefaultUserID) + if err != nil || status.GitHubAppAvailable || status.Personal != nil { + t.Fatalf("status after reset = %+v, err = %v", status, err) + } +} + +func TestMockControllerDeletesWorkspaceAndPersonalConnections(t *testing.T) { + router, _, store := setupWorkspaceAuthMockController(t) + registration := serveMockJSON(t, router, http.MethodPut, + "/api/v1/github/mock/app-registrations/registration-work", + `{"display_name":"Work App","app_id":101}`) + if registration.Code != http.StatusOK { + t.Fatalf("seed App registration: %d %s", registration.Code, registration.Body.String()) + } + for _, seed := range []struct { + path string + body string + }{ + { + path: "/api/v1/github/mock/workspace-connections/workspace-1", + body: `{"source":"github_app_installation","status":"active","app_registration_id":"registration-work","installation_id":42,"installation_account_login":"acme"}`, + }, + { + path: "/api/v1/github/mock/personal-connections/workspace-1", + body: `{"login":"bob","status":"active","github_user_id":7,"access_expires_at":"2030-01-01T00:00:00Z"}`, + }, + } { + response := serveMockJSON(t, router, http.MethodPut, seed.path, seed.body) + if response.Code != http.StatusOK { + t.Fatalf("seed %s: %d %s", seed.path, response.Code, response.Body.String()) + } + } + personal := serveMockJSON(t, router, http.MethodDelete, + "/api/v1/github/mock/personal-connections/workspace-1", "") + if personal.Code != http.StatusOK { + t.Fatalf("delete personal: %d %s", personal.Code, personal.Body.String()) + } + if got, err := store.GetUserConnection(context.Background(), "workspace-1", DefaultUserID); err != nil || got != nil { + t.Fatalf("personal after delete = %+v, err = %v", got, err) + } + workspace := serveMockJSON(t, router, http.MethodDelete, + "/api/v1/github/mock/workspace-connections/workspace-1", "") + if workspace.Code != http.StatusOK { + t.Fatalf("delete workspace: %d %s", workspace.Code, workspace.Body.String()) + } + if got, err := store.GetWorkspaceConnection(context.Background(), "workspace-1"); err != nil || got != nil { + t.Fatalf("workspace after delete = %+v, err = %v", got, err) + } +} + func setupMockControllerTestForAddIssues() (*gin.Engine, *MockClient) { gin.SetMode(gin.TestMode) mock := NewMockClient() diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index 3fa637d508..73b5643baf 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -7,6 +7,240 @@ import ( "time" ) +type AppRegistrationOwnerType string + +const ( + AppRegistrationOwnerUser AppRegistrationOwnerType = "User" + AppRegistrationOwnerOrganization AppRegistrationOwnerType = "Organization" +) + +type DeploymentAppOwnerType = AppRegistrationOwnerType + +const ( + DeploymentAppOwnerUser = AppRegistrationOwnerUser + DeploymentAppOwnerOrganization = AppRegistrationOwnerOrganization +) + +type AppRegistrationSource string + +const ( + AppRegistrationSourceManaged AppRegistrationSource = "managed" + AppRegistrationSourceImported AppRegistrationSource = "imported" +) + +type AppRegistrationVisibility string + +const ( + AppRegistrationVisibilityPrivate AppRegistrationVisibility = "private" + AppRegistrationVisibilityPublic AppRegistrationVisibility = "public" +) + +type AppRegistrationStatus string + +const ( + AppRegistrationStatusActive AppRegistrationStatus = "active" + AppRegistrationStatusInvalid AppRegistrationStatus = "invalid" +) + +type DeploymentAppWebhookStatus string + +const ( + DeploymentAppWebhookUnverified DeploymentAppWebhookStatus = "unverified" + DeploymentAppWebhookVerified DeploymentAppWebhookStatus = "verified" + DeploymentAppWebhookFailing DeploymentAppWebhookStatus = "failing" +) + +// AppRegistration is non-secret metadata for one GitHub App in the deployment catalog. +type AppRegistration struct { + ID string `json:"id" db:"id"` + Source AppRegistrationSource `json:"source" db:"source"` + DisplayName string `json:"display_name" db:"display_name"` + GitHubHost string `json:"github_host" db:"github_host"` + AppID int64 `json:"app_id" db:"app_id"` + ClientID string `json:"client_id" db:"client_id"` + Slug string `json:"slug" db:"slug"` + OwnerLogin string `json:"owner_login" db:"owner_login"` + OwnerType AppRegistrationOwnerType `json:"owner_type" db:"owner_type"` + Visibility AppRegistrationVisibility `json:"visibility" db:"visibility"` + PublicBaseURL string `json:"public_base_url" db:"public_base_url"` + CreatedForWorkspaceID string `json:"created_for_workspace_id,omitempty" db:"created_for_workspace_id"` + CredentialGeneration int64 `json:"credential_generation" db:"credential_generation"` + CredentialSecretID string `json:"-" db:"credential_secret_id"` + Status AppRegistrationStatus `json:"status" db:"status"` + WebhookStatus DeploymentAppWebhookStatus `json:"webhook_status" db:"webhook_status"` + LastWebhookAt *time.Time `json:"last_webhook_at,omitempty" db:"last_webhook_at"` + LastError string `json:"last_error,omitempty" db:"last_error"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type DeploymentAppRegistration = AppRegistration + +// DeploymentAppRegistrationFlow binds one manifest callback to its initiating operator. +type DeploymentAppRegistrationFlow struct { + StateHash string `json:"-" db:"state_hash"` + RegistrationID string `json:"registration_id" db:"registration_id"` + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + UserID string `json:"-" db:"user_id"` + OperatorUserID string `json:"-" db:"operator_user_id"` + OwnerType DeploymentAppOwnerType `json:"owner_type" db:"owner_type"` + OwnerLogin string `json:"owner_login" db:"owner_login"` + DisplayName string `json:"display_name" db:"display_name"` + Visibility AppRegistrationVisibility `json:"visibility" db:"visibility"` + PublicBaseURL string `json:"public_base_url" db:"public_base_url"` + ManifestRevision int `json:"manifest_revision" db:"manifest_revision"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + ConsumedAt *time.Time `json:"-" db:"consumed_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// AppRegistrationImportPreparation reserves one server-generated registration +// identity for a short-lived, single-use existing-App import. +type AppRegistrationImportPreparation struct { + RegistrationID string `json:"registration_id" db:"registration_id"` + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + UserID string `json:"-" db:"user_id"` + PublicBaseURL string `json:"public_base_url" db:"public_base_url"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + ConsumedAt *time.Time `json:"-" db:"consumed_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ConnectionSource identifies the credential source selected for workspace +// automation. Legacy shared auth is migration-only and cannot be selected for +// a new workspace. +type ConnectionSource string + +const ( + ConnectionSourceLegacyShared ConnectionSource = "legacy_shared" + ConnectionSourcePAT ConnectionSource = "pat" + ConnectionSourceGHCLI ConnectionSource = "gh_cli" + ConnectionSourceGitHubAppInstallation ConnectionSource = "github_app_installation" + ConnectionSourceGitHubAppUser ConnectionSource = "github_app_user" +) + +// ConnectionStatus is the persisted health/lifecycle state for a GitHub +// automation or personal connection. +type ConnectionStatus string + +const ( + ConnectionStatusActive ConnectionStatus = "active" + ConnectionStatusInvalid ConnectionStatus = "invalid" + ConnectionStatusSuspended ConnectionStatus = "suspended" + ConnectionStatusRevoked ConnectionStatus = "revoked" +) + +// WorkspaceConnection is the single automation identity selected by a +// workspace. Long-lived secret material is stored separately under a +// deterministic workspace-scoped secret key. +type WorkspaceConnection struct { + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + Source ConnectionSource `json:"source" db:"source"` + GitHubHost string `json:"github_host" db:"github_host"` + Login string `json:"login,omitempty" db:"login"` + InstallationID *int64 `json:"installation_id,omitempty" db:"installation_id"` + InstallationAccountLogin string `json:"installation_account_login,omitempty" db:"installation_account_login"` + InstallationAccountType string `json:"installation_account_type,omitempty" db:"installation_account_type"` + AppRegistrationID string `json:"app_registration_id,omitempty" db:"app_registration_id"` + Status ConnectionStatus `json:"status" db:"status"` + CredentialGeneration int64 `json:"credential_generation" db:"credential_generation"` + LastError string `json:"last_error,omitempty" db:"last_error"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// UserConnection is an optional personal GitHub identity for one Kandev user +// in one workspace. Its OAuth access and refresh tokens live in the encrypted +// secret store and are never placed in this row. +type UserConnection struct { + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + UserID string `json:"user_id" db:"user_id"` + AppRegistrationID string `json:"app_registration_id" db:"app_registration_id"` + GitHubUserID int64 `json:"github_user_id" db:"github_user_id"` + Login string `json:"login" db:"login"` + Status ConnectionStatus `json:"status" db:"status"` + AccessExpiresAt time.Time `json:"access_expires_at" db:"access_expires_at"` + RefreshExpiresAt *time.Time `json:"refresh_expires_at,omitempty" db:"refresh_expires_at"` + CredentialGeneration int64 `json:"credential_generation" db:"credential_generation"` + LastError string `json:"last_error,omitempty" db:"last_error"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type AuthFlowKind string + +const ( + AuthFlowKindAppInstallation AuthFlowKind = "app_installation" + AuthFlowKindPersonal AuthFlowKind = "personal" +) + +// AuthFlow stores one short-lived, single-use GitHub authorization attempt. +// StateHash stores a digest rather than the bearer state returned to a browser. +type AuthFlow struct { + StateHash string `json:"-" db:"state_hash"` + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + UserID string `json:"user_id" db:"user_id"` + AppRegistrationID string `json:"app_registration_id" db:"app_registration_id"` + Kind AuthFlowKind `json:"kind" db:"kind"` + PKCEVerifier string `json:"-" db:"pkce_verifier"` + ExpectedWorkspaceSource ConnectionSource `json:"-" db:"expected_workspace_source"` + ExpectedWorkspaceGeneration int64 `json:"-" db:"expected_workspace_generation"` + ExpectedInstallationID *int64 `json:"-" db:"expected_installation_id"` + ExpectedWorkspaceAppRegistrationID string `json:"-" db:"expected_workspace_app_registration_id"` + ExpectedPersonalGeneration int64 `json:"-" db:"expected_personal_generation"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + ConsumedAt *time.Time `json:"consumed_at,omitempty" db:"consumed_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +type WorkspaceConnectionExpectation struct { + Source ConnectionSource + CredentialGeneration int64 + InstallationID *int64 + AppRegistrationID string +} + +type WebhookDeliveryStatus string + +const ( + WebhookDeliveryStatusReceived WebhookDeliveryStatus = "received" + WebhookDeliveryStatusProcessed WebhookDeliveryStatus = "processed" + WebhookDeliveryStatusIgnored WebhookDeliveryStatus = "ignored" + WebhookDeliveryStatusFailed WebhookDeliveryStatus = "failed" +) + +// WebhookDelivery records GitHub delivery IDs so webhook state transitions are +// idempotent without persisting payloads or authentication material. +type WebhookDelivery struct { + AppRegistrationID string `json:"app_registration_id" db:"app_registration_id"` + DeliveryID string `json:"delivery_id" db:"delivery_id"` + Event string `json:"event" db:"event"` + Status WebhookDeliveryStatus `json:"status" db:"status"` + Result string `json:"result,omitempty" db:"result"` + ReceivedAt time.Time `json:"received_at" db:"received_at"` + ProcessedAt *time.Time `json:"processed_at,omitempty" db:"processed_at"` +} + +type WebhookDeliveryClaim struct { + Acquired bool + Status WebhookDeliveryStatus +} + +// WorkspacePATSecretKey returns the encrypted-secret ID for workspace PAT auth. +func WorkspacePATSecretKey(workspaceID string) string { + return "github:workspace:" + workspaceID + ":pat" +} + +// UserAccessTokenSecretKey returns the encrypted-secret ID for personal access. +func UserAccessTokenSecretKey(workspaceID, userID string) string { + return "github:user:" + workspaceID + ":" + userID + ":access" +} + +// UserRefreshTokenSecretKey returns the encrypted-secret ID for personal refresh. +func UserRefreshTokenSecretKey(workspaceID, userID string) string { + return "github:user:" + workspaceID + ":" + userID + ":refresh" +} + // TaskCIAutoFixMaxRounds is the server-enforced CI auto-fix loop guard. const TaskCIAutoFixMaxRounds = 10 @@ -147,6 +381,7 @@ type IssueSearchPage struct { // for legacy rows). type PRWatch struct { ID string `json:"id" db:"id"` + WorkspaceID string `json:"workspace_id" db:"workspace_id"` SessionID string `json:"session_id" db:"session_id"` TaskID string `json:"task_id" db:"task_id"` RepositoryID string `json:"repository_id,omitempty" db:"repository_id"` @@ -167,6 +402,7 @@ type PRWatch struct { // Empty for legacy rows persisted before multi-repo support. type TaskPR struct { ID string `json:"id" db:"id"` + WorkspaceID string `json:"workspace_id" db:"workspace_id"` TaskID string `json:"task_id" db:"task_id"` RepositoryID string `json:"repository_id,omitempty" db:"repository_id"` Owner string `json:"owner" db:"owner"` @@ -339,6 +575,7 @@ type ReviewWatch struct { Prompt string `json:"prompt" db:"prompt"` ReviewScope string `json:"review_scope" db:"review_scope"` CustomQuery string `json:"custom_query" db:"custom_query"` + TargetLogin string `json:"target_login" db:"target_login"` Enabled bool `json:"enabled" db:"enabled"` PollIntervalSeconds int `json:"poll_interval_seconds" db:"poll_interval_seconds"` CleanupPolicy string `json:"cleanup_policy" db:"cleanup_policy"` @@ -659,8 +896,8 @@ const ( ) // WorkspaceSettings stores per-workspace GitHub operational settings. -// Authentication remains install-wide; these settings scope GitHub UI/search -// surfaces to the workspace's intended repositories. +// Authentication is stored separately and is intentionally not copied with +// these operational settings. type WorkspaceSettings struct { WorkspaceID string `json:"workspace_id" db:"workspace_id"` RepoScopeMode string `json:"repo_scope_mode" db:"repo_scope_mode"` diff --git a/apps/backend/internal/github/noop_client.go b/apps/backend/internal/github/noop_client.go index 5467e6ca3f..44919e2db9 100644 --- a/apps/backend/internal/github/noop_client.go +++ b/apps/backend/internal/github/noop_client.go @@ -57,6 +57,10 @@ func (c *NoopClient) ListAccessibleRepos(context.Context, string, int) ([]GitHub return nil, ErrNoClient } +func (c *NoopClient) HasRepositoryAccess(context.Context, string, string) (bool, error) { + return false, ErrNoClient +} + func (c *NoopClient) ListPRReviews(context.Context, string, string, int) ([]PRReview, error) { return nil, ErrNoClient } diff --git a/apps/backend/internal/github/oauth_flow.go b/apps/backend/internal/github/oauth_flow.go new file mode 100644 index 0000000000..4f98954f32 --- /dev/null +++ b/apps/backend/internal/github/oauth_flow.go @@ -0,0 +1,402 @@ +package github + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + oauthRandomBytes = 32 + appInstallationStateTTL = 10 * time.Minute + personalOAuthStateTTL = 10 * time.Minute +) + +var ( + ErrOAuthStateInvalid = errors.New("GitHub OAuth state is expired, consumed, or invalid") + ErrOAuthStateMismatch = errors.New("GitHub OAuth state does not match the current request") + ErrOAuthFlowStale = errors.New("GitHub authentication changed after authorization started") +) + +func workspaceConnectionExpectation(connection *WorkspaceConnection) WorkspaceConnectionExpectation { + if connection == nil { + return WorkspaceConnectionExpectation{} + } + return WorkspaceConnectionExpectation{ + Source: connection.Source, + CredentialGeneration: connection.CredentialGeneration, + InstallationID: cloneInt64Pointer(connection.InstallationID), + AppRegistrationID: connection.AppRegistrationID, + } +} + +func authFlowWorkspaceExpectation(flow *AuthFlow) WorkspaceConnectionExpectation { + if flow == nil { + return WorkspaceConnectionExpectation{} + } + return WorkspaceConnectionExpectation{ + Source: flow.ExpectedWorkspaceSource, + CredentialGeneration: flow.ExpectedWorkspaceGeneration, + InstallationID: cloneInt64Pointer(flow.ExpectedInstallationID), + AppRegistrationID: flow.ExpectedWorkspaceAppRegistrationID, + } +} + +func matchesWorkspaceConnectionExpectation( + connection *WorkspaceConnection, + expected WorkspaceConnectionExpectation, +) bool { + if connection == nil { + return expected.Source == "" && expected.CredentialGeneration == 0 && + expected.InstallationID == nil && expected.AppRegistrationID == "" + } + return connection.Source == expected.Source && + connection.CredentialGeneration == expected.CredentialGeneration && + equalInt64Pointers(connection.InstallationID, expected.InstallationID) && + connection.AppRegistrationID == expected.AppRegistrationID +} + +func cloneInt64Pointer(value *int64) *int64 { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func equalInt64Pointers(left, right *int64) bool { + return (left == nil && right == nil) || + (left != nil && right != nil && *left == *right) +} + +// GitHubOAuthError preserves the machine-readable OAuth error code so token +// lifecycle decisions do not depend on parsing user-facing text. +type GitHubOAuthError struct { + StatusCode int + Code string + Description string +} + +func (e *GitHubOAuthError) Error() string { + return "GitHub OAuth exchange failed: " + strings.TrimSpace(e.Code+" "+e.Description) +} + +type oauthFlowStore interface { + CreateAuthFlow(context.Context, *AuthFlow) error + ConsumeAuthFlow(context.Context, string, string, AuthFlowKind, time.Time) (*AuthFlow, error) +} + +type OAuthFlowRequest struct { + WorkspaceID string + UserID string + AppRegistrationID string + Kind AuthFlowKind + ExpectedWorkspaceSource ConnectionSource + ExpectedWorkspaceGeneration int64 + ExpectedInstallationID *int64 + ExpectedWorkspaceAppRegistrationID string + ExpectedPersonalGeneration int64 +} + +type OAuthFlowExpectation = OAuthFlowRequest + +type OAuthFlowStart struct { + State string + PKCEChallenge string + ExpiresAt time.Time +} + +type OAuthFlowManager struct { + store oauthFlowStore + now func() time.Time + random io.Reader +} + +func NewOAuthFlowManager(store oauthFlowStore) *OAuthFlowManager { + return &OAuthFlowManager{store: store, now: time.Now, random: rand.Reader} +} + +func (m *OAuthFlowManager) Start(ctx context.Context, request OAuthFlowRequest) (OAuthFlowStart, error) { + if m == nil || m.store == nil { + return OAuthFlowStart{}, errors.New("GitHub OAuth flow store is not configured") + } + if request.WorkspaceID == "" || request.UserID == "" || request.AppRegistrationID == "" { + return OAuthFlowStart{}, errors.New("workspace, user, and App registration are required for GitHub OAuth") + } + ttl, err := oauthFlowTTL(request.Kind) + if err != nil { + return OAuthFlowStart{}, err + } + state, err := randomBase64URL(m.random) + if err != nil { + return OAuthFlowStart{}, fmt.Errorf("generate GitHub OAuth state: %w", err) + } + + var verifier, challenge string + if request.Kind == AuthFlowKindPersonal { + verifier, err = randomBase64URL(m.random) + if err != nil { + return OAuthFlowStart{}, fmt.Errorf("generate GitHub OAuth PKCE verifier: %w", err) + } + challengeDigest := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(challengeDigest[:]) + } + + now := m.now().UTC() + stateDigest := sha256.Sum256([]byte(state)) + flow := &AuthFlow{ + StateHash: stateDigestString(stateDigest), + WorkspaceID: request.WorkspaceID, + UserID: request.UserID, + AppRegistrationID: request.AppRegistrationID, + Kind: request.Kind, + PKCEVerifier: verifier, + ExpectedWorkspaceSource: request.ExpectedWorkspaceSource, + ExpectedWorkspaceGeneration: request.ExpectedWorkspaceGeneration, + ExpectedInstallationID: request.ExpectedInstallationID, + ExpectedWorkspaceAppRegistrationID: request.ExpectedWorkspaceAppRegistrationID, + ExpectedPersonalGeneration: request.ExpectedPersonalGeneration, + ExpiresAt: now.Add(ttl), + CreatedAt: now, + } + if err := m.store.CreateAuthFlow(ctx, flow); err != nil { + return OAuthFlowStart{}, fmt.Errorf("persist GitHub OAuth flow: %w", err) + } + return OAuthFlowStart{State: state, PKCEChallenge: challenge, ExpiresAt: flow.ExpiresAt}, nil +} + +func (m *OAuthFlowManager) Consume( + ctx context.Context, + state string, + expected OAuthFlowExpectation, +) (*AuthFlow, error) { + flow, err := m.ConsumeBound(ctx, state, expected.AppRegistrationID, expected.Kind) + if err != nil { + return nil, err + } + if flow.WorkspaceID != expected.WorkspaceID || flow.UserID != expected.UserID { + return nil, ErrOAuthStateMismatch + } + return flow, nil +} + +// ConsumeBound is for public callbacks that receive only the opaque state. +// The workspace and user are recovered from the consumed server-side row, +// never from untrusted callback query parameters. +func (m *OAuthFlowManager) ConsumeBound( + ctx context.Context, + state string, + registrationID string, + expectedKind AuthFlowKind, +) (*AuthFlow, error) { + if m == nil || m.store == nil || state == "" { + return nil, ErrOAuthStateInvalid + } + digest := sha256.Sum256([]byte(state)) + flow, err := m.store.ConsumeAuthFlow( + ctx, stateDigestString(digest), registrationID, expectedKind, m.now().UTC(), + ) + if err != nil { + if errors.Is(err, ErrAuthFlowUnavailable) { + return nil, ErrOAuthStateInvalid + } + return nil, fmt.Errorf("consume GitHub OAuth flow: %w", err) + } + if flow == nil || flow.Kind != expectedKind || flow.AppRegistrationID != registrationID { + return nil, ErrOAuthStateMismatch + } + return flow, nil +} + +func oauthFlowTTL(kind AuthFlowKind) (time.Duration, error) { + switch kind { + case AuthFlowKindAppInstallation: + return appInstallationStateTTL, nil + case AuthFlowKindPersonal: + return personalOAuthStateTTL, nil + default: + return 0, fmt.Errorf("unsupported GitHub OAuth flow kind %q", kind) + } +} + +func randomBase64URL(source io.Reader) (string, error) { + value := make([]byte, oauthRandomBytes) + if _, err := io.ReadFull(source, value); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func stateDigestString(digest [sha256.Size]byte) string { + return hex.EncodeToString(digest[:]) +} + +// GitHubOAuthClient implements GitHub App user authorization and refresh. It +// never persists tokens; callers hand successful results to the atomic +// personal-connection repository. +type GitHubOAuthClient struct { + clientID string + clientSecret string + httpClient *http.Client + webBaseURL string + apiBaseURL string + now func() time.Time +} + +func NewGitHubOAuthClient(clientID, clientSecret string) *GitHubOAuthClient { + return &GitHubOAuthClient{ + clientID: clientID, clientSecret: clientSecret, + httpClient: &http.Client{Timeout: 30 * time.Second}, + webBaseURL: "https://github.com", apiBaseURL: githubAPIBase, now: time.Now, + } +} + +func (c *GitHubOAuthClient) ExchangeUserCode( + ctx context.Context, + code, pkceVerifier, redirectURI string, +) (GitHubOAuthTokens, error) { + form := url.Values{ + "client_id": {c.clientID}, + "client_secret": {c.clientSecret}, + "code": {code}, + "redirect_uri": {redirectURI}, + } + if pkceVerifier != "" { + form.Set("code_verifier", pkceVerifier) + } + return c.exchangeTokens(ctx, form) +} + +func (c *GitHubOAuthClient) RefreshUserToken( + ctx context.Context, + refreshToken string, +) (GitHubOAuthTokens, error) { + return c.exchangeTokens(ctx, url.Values{ + "client_id": {c.clientID}, + "client_secret": {c.clientSecret}, + "grant_type": {"refresh_token"}, + "refresh_token": {refreshToken}, + }) +} + +func (c *GitHubOAuthClient) GetOAuthUser(ctx context.Context, accessToken string) (GitHubOAuthUser, error) { + request, err := c.apiRequest(ctx, http.MethodGet, "/user", accessToken) + if err != nil { + return GitHubOAuthUser{}, err + } + response, err := c.httpClient.Do(request) + if err != nil { + return GitHubOAuthUser{}, fmt.Errorf("request GitHub OAuth user: %w", err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode >= http.StatusBadRequest { + return GitHubOAuthUser{}, &GitHubAPIError{StatusCode: response.StatusCode, Endpoint: "/user"} + } + var user GitHubOAuthUser + if err := json.NewDecoder(io.LimitReader(response.Body, maxGitHubAppResponseSize)).Decode(&user); err != nil { + return GitHubOAuthUser{}, fmt.Errorf("decode GitHub OAuth user: %w", err) + } + return user, nil +} + +func (c *GitHubOAuthClient) UserCanAccessInstallation( + ctx context.Context, + accessToken string, + installationID int64, +) (bool, error) { + endpoint := fmt.Sprintf("/user/installations/%d", installationID) + request, err := c.apiRequest(ctx, http.MethodGet, endpoint, accessToken) + if err != nil { + return false, err + } + response, err := c.httpClient.Do(request) + if err != nil { + return false, fmt.Errorf("request GitHub user installation: %w", err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode == http.StatusNotFound { + return false, nil + } + if response.StatusCode >= http.StatusBadRequest { + return false, &GitHubAPIError{StatusCode: response.StatusCode, Endpoint: endpoint} + } + return true, nil +} + +func (c *GitHubOAuthClient) exchangeTokens(ctx context.Context, form url.Values) (GitHubOAuthTokens, error) { + if c == nil || c.clientID == "" || c.clientSecret == "" { + return GitHubOAuthTokens{}, errors.New("GitHub App OAuth client is not configured") + } + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + strings.TrimRight(c.webBaseURL, "/")+"/login/oauth/access_token", + strings.NewReader(form.Encode()), + ) + if err != nil { + return GitHubOAuthTokens{}, err + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := c.httpClient.Do(request) + if err != nil { + return GitHubOAuthTokens{}, fmt.Errorf("exchange GitHub OAuth token: %w", err) + } + defer func() { _ = response.Body.Close() }() + var body struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := json.NewDecoder(io.LimitReader(response.Body, maxGitHubAppResponseSize)).Decode(&body); err != nil { + return GitHubOAuthTokens{}, fmt.Errorf("decode GitHub OAuth token response: %w", err) + } + if response.StatusCode >= http.StatusBadRequest || body.Error != "" { + return GitHubOAuthTokens{}, &GitHubOAuthError{ + StatusCode: response.StatusCode, + Code: body.Error, Description: body.ErrorDescription, + } + } + now := c.now().UTC() + tokens := GitHubOAuthTokens{ + AccessToken: body.AccessToken, + RefreshToken: body.RefreshToken, + } + if body.ExpiresIn > 0 { + tokens.AccessExpiresAt = now.Add(time.Duration(body.ExpiresIn) * time.Second) + } + if body.RefreshTokenExpiresIn > 0 { + expiresAt := now.Add(time.Duration(body.RefreshTokenExpiresIn) * time.Second) + tokens.RefreshExpiresAt = &expiresAt + } + return tokens, nil +} + +func (c *GitHubOAuthClient) apiRequest( + ctx context.Context, + method, endpoint, accessToken string, +) (*http.Request, error) { + request, err := http.NewRequestWithContext( + ctx, method, strings.TrimRight(c.apiBaseURL, "/")+endpoint, nil, + ) + if err != nil { + return nil, err + } + request.Header.Set("Accept", githubAccept) + request.Header.Set("Authorization", "Bearer "+accessToken) + request.Header.Set("X-GitHub-Api-Version", githubAPIVersion) + return request, nil +} diff --git a/apps/backend/internal/github/oauth_flow_test.go b/apps/backend/internal/github/oauth_flow_test.go new file mode 100644 index 0000000000..2e22785ba1 --- /dev/null +++ b/apps/backend/internal/github/oauth_flow_test.go @@ -0,0 +1,202 @@ +package github + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type oauthFlowMemoryStore struct { + flows map[string]*AuthFlow +} + +func (s *oauthFlowMemoryStore) CreateAuthFlow(_ context.Context, flow *AuthFlow) error { + if s.flows == nil { + s.flows = make(map[string]*AuthFlow) + } + copy := *flow + s.flows[flow.StateHash] = © + return nil +} + +func (s *oauthFlowMemoryStore) ConsumeAuthFlow( + _ context.Context, + stateHash, registrationID string, + kind AuthFlowKind, + now time.Time, +) (*AuthFlow, error) { + flow := s.flows[stateHash] + if flow == nil || flow.AppRegistrationID != registrationID || flow.Kind != kind || + flow.ConsumedAt != nil || !flow.ExpiresAt.After(now) { + return nil, ErrAuthFlowUnavailable + } + copy := *flow + copy.ConsumedAt = &now + flow.ConsumedAt = &now + return ©, nil +} + +func TestOAuthFlowStartPersistsOnlyStateDigestAndPKCEVerifier(t *testing.T) { + store := &oauthFlowMemoryStore{} + manager := NewOAuthFlowManager(store) + manager.now = func() time.Time { return time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) } + manager.random = strings.NewReader(strings.Repeat("a", oauthRandomBytes*2)) + + installationID := int64(42) + started, err := manager.Start(context.Background(), OAuthFlowRequest{ + WorkspaceID: "workspace-1", + UserID: "user-1", + AppRegistrationID: "registration-test", + Kind: AuthFlowKindPersonal, + ExpectedWorkspaceSource: ConnectionSourceGitHubAppInstallation, + ExpectedWorkspaceGeneration: 3, + ExpectedInstallationID: &installationID, + ExpectedWorkspaceAppRegistrationID: "registration-test", + ExpectedPersonalGeneration: 7, + }) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + if started.State == "" || started.PKCEChallenge == "" { + t.Fatalf("Start() = %+v, want state and PKCE challenge", started) + } + digest := sha256.Sum256([]byte(started.State)) + persisted := store.flows[stateDigestString(digest)] + if persisted == nil { + t.Fatal("state digest was not persisted") + } + if strings.Contains(persisted.StateHash, started.State) || persisted.PKCEVerifier == "" { + t.Fatalf("persisted flow leaked state or omitted verifier: %+v", persisted) + } + if got, want := persisted.ExpiresAt.Sub(manager.now()), personalOAuthStateTTL; got != want { + t.Fatalf("flow TTL = %v, want %v", got, want) + } + if persisted.ExpectedWorkspaceSource != ConnectionSourceGitHubAppInstallation || + persisted.ExpectedWorkspaceGeneration != 3 || persisted.ExpectedInstallationID == nil || + *persisted.ExpectedInstallationID != 42 || persisted.ExpectedPersonalGeneration != 7 { + t.Fatalf("persisted flow expectation = %+v", persisted) + } +} + +func TestOAuthFlowConsumeRejectsMismatchAndReplay(t *testing.T) { + store := &oauthFlowMemoryStore{} + manager := NewOAuthFlowManager(store) + manager.now = func() time.Time { return time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) } + manager.random = io.LimitReader(strings.NewReader(strings.Repeat("b", oauthRandomBytes)), oauthRandomBytes) + + started, err := manager.Start(context.Background(), OAuthFlowRequest{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test", + Kind: AuthFlowKindAppInstallation, + }) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + _, err = manager.Consume(context.Background(), started.State, OAuthFlowExpectation{ + WorkspaceID: "other-workspace", UserID: "user-1", AppRegistrationID: "registration-test", + Kind: AuthFlowKindAppInstallation, + }) + if !errors.Is(err, ErrOAuthStateMismatch) { + t.Fatalf("Consume() mismatch error = %v, want %v", err, ErrOAuthStateMismatch) + } + _, err = manager.Consume(context.Background(), started.State, OAuthFlowExpectation{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test", + Kind: AuthFlowKindAppInstallation, + }) + if !errors.Is(err, ErrOAuthStateInvalid) { + t.Fatalf("Consume() replay error = %v, want %v", err, ErrOAuthStateInvalid) + } +} + +func TestOAuthFlowConsumeRejectsExpiredState(t *testing.T) { + store := &oauthFlowMemoryStore{} + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + manager := NewOAuthFlowManager(store) + manager.now = func() time.Time { return now } + manager.random = strings.NewReader(strings.Repeat("c", oauthRandomBytes)) + started, err := manager.Start(context.Background(), OAuthFlowRequest{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test", + Kind: AuthFlowKindAppInstallation, + }) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + manager.now = func() time.Time { return now.Add(appInstallationStateTTL + time.Second) } + _, err = manager.Consume(context.Background(), started.State, OAuthFlowExpectation{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test", + Kind: AuthFlowKindAppInstallation, + }) + if !errors.Is(err, ErrOAuthStateInvalid) { + t.Fatalf("Consume() expired error = %v, want %v", err, ErrOAuthStateInvalid) + } +} + +func TestOAuthFlowGitHubClientExchangesRefreshesAndVerifiesUser(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/login/oauth/access_token": + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm() error = %v", err) + } + if r.Form.Get("client_id") != "client-id" || r.Form.Get("client_secret") != "client-secret" { + t.Fatalf("OAuth client credentials = %v", r.Form) + } + if r.Form.Get("grant_type") == "refresh_token" { + if r.Form.Get("refresh_token") != "old-refresh" { + t.Fatalf("refresh form = %v", r.Form) + } + } else if r.Form.Get("code") != "code" || r.Form.Get("code_verifier") != "verifier" { + t.Fatalf("exchange form = %v", r.Form) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "access", "refresh_token": "refresh", + "expires_in": 28800, "refresh_token_expires_in": 15552000, + }) + case "/user": + if r.Header.Get("Authorization") != "Bearer access" { + t.Fatalf("Authorization = %q", r.Header.Get("Authorization")) + } + _ = json.NewEncoder(w).Encode(map[string]any{"id": 11, "login": "octocat"}) + case "/user/installations/42": + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := NewGitHubOAuthClient("client-id", "client-secret") + client.webBaseURL = server.URL + client.apiBaseURL = server.URL + client.httpClient = server.Client() + client.now = func() time.Time { return now } + tokens, err := client.ExchangeUserCode(context.Background(), "code", "verifier", "https://kandev.example/callback") + if err != nil { + t.Fatalf("ExchangeUserCode() error = %v", err) + } + if tokens.AccessExpiresAt != now.Add(8*time.Hour) || tokens.RefreshExpiresAt == nil { + t.Fatalf("ExchangeUserCode() = %+v", tokens) + } + if _, err := client.RefreshUserToken(context.Background(), "old-refresh"); err != nil { + t.Fatalf("RefreshUserToken() error = %v", err) + } + user, err := client.GetOAuthUser(context.Background(), tokens.AccessToken) + if err != nil || user.ID != 11 || user.Login != "octocat" { + t.Fatalf("GetOAuthUser() = %+v, %v", user, err) + } + accessible, err := client.UserCanAccessInstallation(context.Background(), tokens.AccessToken, 42) + if err != nil || !accessible { + t.Fatalf("UserCanAccessInstallation() = %v, %v", accessible, err) + } + accessible, err = client.UserCanAccessInstallation(context.Background(), tokens.AccessToken, 99) + if err != nil || accessible { + t.Fatalf("missing UserCanAccessInstallation() = %v, %v", accessible, err) + } +} diff --git a/apps/backend/internal/github/pat_client.go b/apps/backend/internal/github/pat_client.go index 99979aa2aa..51755e8c9e 100644 --- a/apps/backend/internal/github/pat_client.go +++ b/apps/backend/internal/github/pat_client.go @@ -38,24 +38,6 @@ func (e *GitHubAPIError) Error() string { return fmt.Sprintf("GitHub API %s returned %d: %s", e.Endpoint, e.StatusCode, e.Body) } -// PATClient implements Client using a GitHub Personal Access Token. -type PATClient struct { - token string - httpClient *http.Client - username string // cached after first GetAuthenticatedUser call - rateTracker *RateTracker -} - -// NewPATClient creates a new PAT-based GitHub client. -func NewPATClient(token string) *PATClient { - return &PATClient{ - token: token, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - // WithRateTracker attaches a rate tracker so response headers are recorded. // Returns the client for chaining; safe to call before any requests are made. func (c *PATClient) WithRateTracker(t *RateTracker) *PATClient { @@ -157,6 +139,19 @@ func (c *PATClient) GetAuthenticatedUser(ctx context.Context) (string, error) { return c.username, nil } +// HasRepositoryAccess checks the repository root without invoking any +// operation-specific endpoint or mutating service caches. +func (c *PATClient) HasRepositoryAccess(ctx context.Context, owner, repo string) (bool, error) { + var metadata struct { + FullName string `json:"full_name"` + } + endpoint := fmt.Sprintf("/repos/%s/%s", owner, repo) + if err := c.get(ctx, endpoint, &metadata); err != nil { + return false, err + } + return metadata.FullName != "", nil +} + func (c *PATClient) GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) { var raw patPR endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, number) @@ -359,6 +354,16 @@ func (c *PATClient) ListUserRepos(ctx context.Context, query string, limit int) // without revisiting the picker contract. func (c *PATClient) ListAccessibleRepos(ctx context.Context, query string, limit int) ([]GitHubRepo, error) { limit = clampRepoSearchLimit(limit) + if c.principal.Kind == TokenCredentialInstallation { + endpoint := fmt.Sprintf("/installation/repositories?per_page=%d", limit) + var response struct { + Repositories []repoListItem `json:"repositories"` + } + if err := c.get(ctx, endpoint, &response); err != nil { + return nil, fmt.Errorf("list installation repositories: %w", err) + } + return filterReposByQuery(convertRepoListItems(response.Repositories), query), nil + } endpoint := fmt.Sprintf("/user/repos?affiliation=%s&sort=pushed&per_page=%d", url.QueryEscape(accessibleReposAffiliation), limit) var items []repoListItem diff --git a/apps/backend/internal/github/pat_client_test.go b/apps/backend/internal/github/pat_client_test.go index cee4f268d6..86f1667543 100644 --- a/apps/backend/internal/github/pat_client_test.go +++ b/apps/backend/internal/github/pat_client_test.go @@ -866,6 +866,32 @@ func TestListAccessibleRepos_QueryFilterAndClamp(t *testing.T) { } } +func TestListAccessibleRepos_InstallationUsesInstallationEndpoint(t *testing.T) { + var gotPerPage string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/installation/repositories" { + t.Errorf("unexpected path %q", r.URL.Path) + http.Error(w, "unexpected path", http.StatusInternalServerError) + return + } + gotPerPage = r.URL.Query().Get("per_page") + _, _ = w.Write([]byte(`{"repositories":[ + {"full_name":"acme/widget","owner":{"login":"acme"},"name":"widget","private":true,"default_branch":"main"} + ]}`)) + })) + t.Cleanup(srv.Close) + + c := NewTokenClient("installation-token", TokenPrincipal{Kind: TokenCredentialInstallation}) + c.httpClient = &http.Client{Transport: &rewriteTransport{base: srv.URL}} + repos, err := c.ListAccessibleRepos(context.Background(), "widget", 50) + if err != nil { + t.Fatalf("ListAccessibleRepos: %v", err) + } + if gotPerPage != "50" || len(repos) != 1 || repos[0].FullName != "acme/widget" { + t.Fatalf("per_page=%q repos=%+v", gotPerPage, repos) + } +} + func TestListAccessibleRepos_AuthError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/user/repos" { diff --git a/apps/backend/internal/github/personal_auth_service.go b/apps/backend/internal/github/personal_auth_service.go new file mode 100644 index 0000000000..81256c7a8c --- /dev/null +++ b/apps/backend/internal/github/personal_auth_service.go @@ -0,0 +1,554 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +const ( + personalTokenRefreshMargin = 5 * time.Minute + personalTokenRefreshTimeout = 30 * time.Second +) + +var ( + ErrPersonalIdentityChanged = errors.New("refreshed GitHub identity does not match the connected user") + ErrPersonalTokenInvalid = errors.New("personal GitHub token is missing or expired") + ErrPersonalConnectionStale = errors.New("personal GitHub connection changed") +) + +type personalOAuth interface { + githubAppOAuth + RefreshUserToken(context.Context, string) (GitHubOAuthTokens, error) +} + +// PersonalConnectionRepository owns the atomic boundary between encrypted +// personal secrets and user-connection metadata. Replace must either commit +// all values or preserve the previous row and both previous secrets. +type PersonalConnectionRepository interface { + GetWorkspaceConnection(context.Context, string) (*WorkspaceConnection, error) + GetUserConnection(context.Context, string, string) (*UserConnection, error) + GetPersonalConnectionGeneration(context.Context, string, string) (int64, error) + GetPersonalTokens(context.Context, string, string) (GitHubOAuthTokens, error) + ReplacePersonalConnection(context.Context, *UserConnection, GitHubOAuthTokens, int64) error + ReplacePersonalConnectionForFlow( + context.Context, + *UserConnection, + GitHubOAuthTokens, + int64, + WorkspaceConnectionExpectation, + ) error + MarkPersonalConnectionInvalid(context.Context, string, string, int64, error) error + RevokePersonalConnection(context.Context, string, string) error + RevokePersonalConnectionIfUnchanged(context.Context, *UserConnection) (bool, error) + RevokeWorkspacePersonalConnections(context.Context, string) error +} + +type PersonalAuthConfig struct { + RegistrationID string + ClientID string + CallbackURL string +} + +type PersonalAuthStart struct { + URL string `json:"url"` + State string `json:"state"` + ExpiresAt time.Time `json:"expires_at"` +} + +type PersonalAuthCallback struct { + WorkspaceID string + UserID string + State string + Code string +} + +type PersonalAuthResult struct { + WorkspaceID string + Connection UserConnection +} + +type PersonalAuthService struct { + config PersonalAuthConfig + flows *OAuthFlowManager + repo PersonalConnectionRepository + oauth personalOAuth + now func() time.Time + refresh singleflight.Group + workspaceMutationLock func(string) *sync.Mutex +} + +func NewPersonalAuthService( + config PersonalAuthConfig, + flows *OAuthFlowManager, + repo PersonalConnectionRepository, + oauth personalOAuth, +) *PersonalAuthService { + return &PersonalAuthService{config: config, flows: flows, repo: repo, oauth: oauth, now: time.Now} +} + +func (s *PersonalAuthService) SetWorkspaceMutationLock(lock func(string) *sync.Mutex) { + if s != nil { + s.workspaceMutationLock = lock + } +} + +func (s *PersonalAuthService) Start( + ctx context.Context, + workspaceID, userID string, +) (PersonalAuthStart, error) { + if s == nil || s.flows == nil || s.repo == nil { + return PersonalAuthStart{}, errors.New("personal GitHub authentication is not configured") + } + if err := validatePersonalAuthConfig(s.config); err != nil { + return PersonalAuthStart{}, err + } + workspace, err := s.requireActiveAppWorkspace(ctx, workspaceID) + if err != nil { + return PersonalAuthStart{}, err + } + personalGeneration, err := s.repo.GetPersonalConnectionGeneration(ctx, workspaceID, userID) + if err != nil { + return PersonalAuthStart{}, fmt.Errorf("load personal GitHub connection: %w", err) + } + expected := workspaceConnectionExpectation(workspace) + flow, err := s.flows.Start(ctx, OAuthFlowRequest{ + WorkspaceID: workspaceID, + UserID: userID, + AppRegistrationID: s.config.RegistrationID, + Kind: AuthFlowKindPersonal, + ExpectedWorkspaceSource: expected.Source, + ExpectedWorkspaceGeneration: expected.CredentialGeneration, + ExpectedInstallationID: expected.InstallationID, + ExpectedWorkspaceAppRegistrationID: expected.AppRegistrationID, + ExpectedPersonalGeneration: personalGeneration, + }) + if err != nil { + return PersonalAuthStart{}, err + } + authorizeURL := &url.URL{Scheme: "https", Host: "github.com", Path: "/login/oauth/authorize"} + query := authorizeURL.Query() + query.Set("client_id", s.config.ClientID) + query.Set("redirect_uri", s.config.CallbackURL) + query.Set("state", flow.State) + query.Set("code_challenge", flow.PKCEChallenge) + query.Set("code_challenge_method", "S256") + authorizeURL.RawQuery = query.Encode() + return PersonalAuthStart{URL: authorizeURL.String(), State: flow.State, ExpiresAt: flow.ExpiresAt}, nil +} + +func (s *PersonalAuthService) Complete( + ctx context.Context, + callback PersonalAuthCallback, +) (PersonalAuthResult, error) { + if s == nil || s.flows == nil || s.repo == nil || s.oauth == nil { + return PersonalAuthResult{}, errors.New("personal GitHub authentication is not configured") + } + flow, err := consumeCallbackFlow( + ctx, s.flows, callback.State, callback.WorkspaceID, callback.UserID, + s.config.RegistrationID, AuthFlowKindPersonal, + ) + if err != nil { + return PersonalAuthResult{}, err + } + unlock := s.lockWorkspace(flow.WorkspaceID) + defer unlock() + workspace, err := s.requireActiveAppWorkspace(ctx, flow.WorkspaceID) + if err != nil { + return PersonalAuthResult{}, err + } + if !matchesWorkspaceConnectionExpectation(workspace, authFlowWorkspaceExpectation(flow)) { + return PersonalAuthResult{}, ErrOAuthFlowStale + } + tokens, user, err := s.verifyPersonalCallback(ctx, callback, flow, *workspace.InstallationID) + if err != nil { + return PersonalAuthResult{}, err + } + connection, err := s.savePersonalConnection(ctx, flow, user, tokens) + if err != nil { + return PersonalAuthResult{}, err + } + return PersonalAuthResult{WorkspaceID: flow.WorkspaceID, Connection: connection}, nil +} + +func (s *PersonalAuthService) verifyPersonalCallback( + ctx context.Context, + callback PersonalAuthCallback, + flow *AuthFlow, + installationID int64, +) (GitHubOAuthTokens, GitHubOAuthUser, error) { + if callback.Code == "" || flow.PKCEVerifier == "" { + return GitHubOAuthTokens{}, GitHubOAuthUser{}, ErrPersonalTokenInvalid + } + tokens, err := s.oauth.ExchangeUserCode(ctx, callback.Code, flow.PKCEVerifier, s.config.CallbackURL) + if err != nil { + return GitHubOAuthTokens{}, GitHubOAuthUser{}, fmt.Errorf( + "exchange personal GitHub authorization: %w", err, + ) + } + if err := validatePersonalTokens(tokens, s.now()); err != nil { + return GitHubOAuthTokens{}, GitHubOAuthUser{}, err + } + user, err := s.oauth.GetOAuthUser(ctx, tokens.AccessToken) + if err != nil || user.ID <= 0 || strings.TrimSpace(user.Login) == "" { + return GitHubOAuthTokens{}, GitHubOAuthUser{}, fmt.Errorf( + "verify personal GitHub user: %w", associationError(err), + ) + } + accessible, err := s.oauth.UserCanAccessInstallation(ctx, tokens.AccessToken, installationID) + if err != nil || !accessible { + return GitHubOAuthTokens{}, GitHubOAuthUser{}, fmt.Errorf( + "verify personal GitHub installation access: %w", associationError(err), + ) + } + return tokens, user, nil +} + +func (s *PersonalAuthService) savePersonalConnection( + ctx context.Context, + flow *AuthFlow, + user GitHubOAuthUser, + tokens GitHubOAuthTokens, +) (UserConnection, error) { + existing, err := s.repo.GetUserConnection(ctx, flow.WorkspaceID, flow.UserID) + if err != nil { + return UserConnection{}, fmt.Errorf("load personal GitHub connection: %w", err) + } + currentGeneration, err := s.repo.GetPersonalConnectionGeneration(ctx, flow.WorkspaceID, flow.UserID) + if err != nil { + return UserConnection{}, fmt.Errorf("load personal GitHub generation: %w", err) + } + connection := newUserConnection( + flow.WorkspaceID, flow.UserID, flow.AppRegistrationID, user, tokens, existing, + ) + connection.CredentialGeneration = flow.ExpectedPersonalGeneration + 1 + if currentGeneration != flow.ExpectedPersonalGeneration { + return UserConnection{}, ErrOAuthFlowStale + } + if err := s.repo.ReplacePersonalConnectionForFlow( + ctx, + &connection, + tokens, + flow.ExpectedPersonalGeneration, + authFlowWorkspaceExpectation(flow), + ); err != nil { + return UserConnection{}, fmt.Errorf("atomically save personal GitHub connection: %w", err) + } + return connection, nil +} + +func (s *PersonalAuthService) ResolvePersonalToken( + ctx context.Context, + workspaceID, userID string, +) (string, *UserConnection, error) { + if s == nil || s.repo == nil || s.oauth == nil { + return "", nil, ErrGitHubPersonalRequired + } + workspace, err := s.requireActiveAppWorkspace(ctx, workspaceID) + if err != nil { + return "", nil, err + } + connection, tokens, err := s.loadPersonalConnection(ctx, workspaceID, userID) + if err != nil { + return "", nil, err + } + now := s.now().UTC() + if tokens.AccessToken != "" && tokens.AccessExpiresAt.After(now.Add(personalTokenRefreshMargin)) { + return tokens.AccessToken, connection, nil + } + return s.coalescedPersonalTokenRefresh(ctx, workspaceID, userID, *workspace.InstallationID) +} + +type personalRefreshResult struct { + token string + connection *UserConnection +} + +func (s *PersonalAuthService) coalescedPersonalTokenRefresh( + ctx context.Context, + workspaceID, userID string, + installationID int64, +) (string, *UserConnection, error) { + key := fmt.Sprintf( + "%d:%s|%d:%s|%d:%s", + len(s.config.RegistrationID), s.config.RegistrationID, + len(workspaceID), workspaceID, len(userID), userID, + ) + resultChannel := s.refresh.DoChan(key, func() (any, error) { + refreshCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), personalTokenRefreshTimeout) + defer cancel() + connection, tokens, loadErr := s.loadPersonalConnection(refreshCtx, workspaceID, userID) + if loadErr != nil { + return nil, loadErr + } + now := s.now().UTC() + if tokens.AccessToken != "" && tokens.AccessExpiresAt.After(now.Add(personalTokenRefreshMargin)) { + return personalRefreshResult{token: tokens.AccessToken, connection: connection}, nil + } + token, refreshedConnection, refreshErr := s.refreshPersonalToken( + refreshCtx, workspaceID, userID, installationID, connection, tokens, now, + ) + if refreshErr != nil { + return nil, refreshErr + } + return personalRefreshResult{token: token, connection: refreshedConnection}, nil + }) + select { + case <-ctx.Done(): + return "", nil, ctx.Err() + case result := <-resultChannel: + if result.Err != nil { + return "", nil, result.Err + } + value := result.Val.(personalRefreshResult) + return value.token, value.connection, nil + } +} + +func (s *PersonalAuthService) loadPersonalConnection( + ctx context.Context, + workspaceID, userID string, +) (*UserConnection, GitHubOAuthTokens, error) { + connection, err := s.repo.GetUserConnection(ctx, workspaceID, userID) + if err != nil { + return nil, GitHubOAuthTokens{}, fmt.Errorf("load personal GitHub connection: %w", err) + } + if connection == nil || connection.Status != ConnectionStatusActive { + return nil, GitHubOAuthTokens{}, ErrGitHubPersonalRequired + } + if connection.AppRegistrationID != s.config.RegistrationID { + return nil, GitHubOAuthTokens{}, ErrGitHubPersonalRequired + } + tokens, err := s.repo.GetPersonalTokens(ctx, workspaceID, userID) + if err != nil { + return nil, GitHubOAuthTokens{}, fmt.Errorf("load personal GitHub tokens: %w", err) + } + return connection, tokens, nil +} + +func (s *PersonalAuthService) refreshPersonalToken( + ctx context.Context, + workspaceID, userID string, + installationID int64, + connection *UserConnection, + tokens GitHubOAuthTokens, + now time.Time, +) (string, *UserConnection, error) { + if tokens.RefreshToken == "" || (tokens.RefreshExpiresAt != nil && !tokens.RefreshExpiresAt.After(now)) { + s.markInvalid(ctx, workspaceID, userID, connection.CredentialGeneration, ErrPersonalTokenInvalid) + return "", nil, ErrPersonalTokenInvalid + } + + refreshed, err := s.oauth.RefreshUserToken(ctx, tokens.RefreshToken) + if err != nil { + if isDefinitivePersonalAuthFailure(err) { + s.markInvalid(ctx, workspaceID, userID, connection.CredentialGeneration, err) + } + return "", nil, fmt.Errorf("refresh personal GitHub token: %w", err) + } + if err := validatePersonalTokens(refreshed, now); err != nil { + s.markInvalid(ctx, workspaceID, userID, connection.CredentialGeneration, err) + return "", nil, err + } + user, err := s.verifyRefreshedPersonalUser(ctx, workspaceID, userID, installationID, connection, refreshed) + if err != nil { + return "", nil, err + } + replacement := newUserConnection( + workspaceID, userID, s.config.RegistrationID, user, refreshed, connection, + ) + if err := s.repo.ReplacePersonalConnection( + ctx, &replacement, refreshed, connection.CredentialGeneration, + ); err != nil { + return "", nil, fmt.Errorf("atomically rotate personal GitHub tokens: %w", err) + } + return refreshed.AccessToken, &replacement, nil +} + +func (s *PersonalAuthService) verifyRefreshedPersonalUser( + ctx context.Context, + workspaceID, userID string, + installationID int64, + connection *UserConnection, + refreshed GitHubOAuthTokens, +) (GitHubOAuthUser, error) { + user, err := s.oauth.GetOAuthUser(ctx, refreshed.AccessToken) + if err != nil { + if isDefinitivePersonalAuthFailure(err) { + s.markInvalid(ctx, workspaceID, userID, connection.CredentialGeneration, err) + } + return GitHubOAuthUser{}, fmt.Errorf("verify refreshed personal GitHub user: %w", err) + } + if user.ID != connection.GitHubUserID { + s.markInvalid(ctx, workspaceID, userID, connection.CredentialGeneration, ErrPersonalIdentityChanged) + return GitHubOAuthUser{}, ErrPersonalIdentityChanged + } + accessible, err := s.oauth.UserCanAccessInstallation(ctx, refreshed.AccessToken, installationID) + if err != nil || !accessible { + associationErr := associationError(err) + if err == nil || isDefinitivePersonalAuthFailure(err) { + s.markInvalid(ctx, workspaceID, userID, connection.CredentialGeneration, associationErr) + } + return GitHubOAuthUser{}, associationErr + } + return user, nil +} + +func (s *PersonalAuthService) Revoke(ctx context.Context, workspaceID, userID string) error { + if s == nil || s.repo == nil { + return nil + } + unlock := s.lockWorkspace(workspaceID) + defer unlock() + return s.repo.RevokePersonalConnection(ctx, workspaceID, userID) +} + +func (s *PersonalAuthService) lockWorkspace(workspaceID string) func() { + if s.workspaceMutationLock == nil { + return func() {} + } + lock := s.workspaceMutationLock(workspaceID) + lock.Lock() + return lock.Unlock +} + +func (s *PersonalAuthService) RevokeWorkspace(ctx context.Context, workspaceID string) error { + if s == nil || s.repo == nil { + return nil + } + return s.repo.RevokeWorkspacePersonalConnections(ctx, workspaceID) +} + +func (s *PersonalAuthService) ReconcileAuthorizationRevocation( + ctx context.Context, + snapshot *UserConnection, +) (bool, error) { + if s == nil || s.repo == nil || s.oauth == nil || snapshot == nil { + return false, nil + } + unlock := s.lockWorkspace(snapshot.WorkspaceID) + defer unlock() + current, err := s.repo.GetUserConnection(ctx, snapshot.WorkspaceID, snapshot.UserID) + if err != nil || !sameUserConnectionVersion(current, snapshot) { + return false, err + } + tokens, err := s.repo.GetPersonalTokens(ctx, snapshot.WorkspaceID, snapshot.UserID) + if err != nil { + return false, err + } + user, err := s.oauth.GetOAuthUser(ctx, tokens.AccessToken) + if err == nil && user.ID == current.GitHubUserID { + return false, nil + } + if err != nil && !isDefinitivePersonalAuthFailure(err) { + return false, err + } + return s.repo.RevokePersonalConnectionIfUnchanged(ctx, current) +} + +func isDefinitivePersonalAuthFailure(err error) bool { + var oauthErr *GitHubOAuthError + if errors.As(err, &oauthErr) { + return oauthErr.Code == "invalid_grant" || oauthErr.StatusCode == http.StatusUnauthorized + } + var apiErr *GitHubAPIError + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized +} + +func (s *PersonalAuthService) requireActiveAppWorkspace( + ctx context.Context, + workspaceID string, +) (*WorkspaceConnection, error) { + connection, err := s.repo.GetWorkspaceConnection(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("load GitHub workspace connection: %w", err) + } + if connection == nil || connection.Source != ConnectionSourceGitHubAppInstallation || + connection.Status != ConnectionStatusActive || connection.InstallationID == nil || + connection.AppRegistrationID != s.config.RegistrationID { + return nil, ErrGitHubPersonalRequired + } + return connection, nil +} + +func (s *PersonalAuthService) markInvalid( + ctx context.Context, + workspaceID, userID string, + expectedGeneration int64, + reason error, +) { + _ = s.repo.MarkPersonalConnectionInvalid(ctx, workspaceID, userID, expectedGeneration, reason) +} + +func personalConnectionGeneration(connection *UserConnection) int64 { + if connection == nil { + return 0 + } + return connection.CredentialGeneration +} + +func sameUserConnectionVersion(current, expected *UserConnection) bool { + return current != nil && expected != nil && + current.WorkspaceID == expected.WorkspaceID && current.UserID == expected.UserID && + current.AppRegistrationID == expected.AppRegistrationID && + current.GitHubUserID == expected.GitHubUserID && + current.CredentialGeneration == expected.CredentialGeneration && + current.UpdatedAt.Equal(expected.UpdatedAt) +} + +func newUserConnection( + workspaceID, userID, registrationID string, + user GitHubOAuthUser, + tokens GitHubOAuthTokens, + existing *UserConnection, +) UserConnection { + generation := int64(1) + var createdAt time.Time + if existing != nil { + generation = existing.CredentialGeneration + 1 + createdAt = existing.CreatedAt + } + return UserConnection{ + WorkspaceID: workspaceID, + UserID: userID, + AppRegistrationID: registrationID, + GitHubUserID: user.ID, + Login: user.Login, + Status: ConnectionStatusActive, + AccessExpiresAt: tokens.AccessExpiresAt, + RefreshExpiresAt: tokens.RefreshExpiresAt, + CredentialGeneration: generation, + CreatedAt: createdAt, + } +} + +func validatePersonalTokens(tokens GitHubOAuthTokens, now time.Time) error { + if tokens.AccessToken == "" || !tokens.AccessExpiresAt.After(now) { + return ErrPersonalTokenInvalid + } + if tokens.RefreshExpiresAt != nil && !tokens.RefreshExpiresAt.After(now) { + return ErrPersonalTokenInvalid + } + return nil +} + +func validatePersonalAuthConfig(config PersonalAuthConfig) error { + if strings.TrimSpace(config.RegistrationID) == "" { + return errors.New("GitHub App registration ID is required") + } + if strings.TrimSpace(config.ClientID) == "" { + return errors.New("GitHub App client ID is required") + } + callback, err := url.Parse(config.CallbackURL) + if err != nil || !callback.IsAbs() || callback.Host == "" { + return errors.New("personal GitHub callback URL must be absolute") + } + return nil +} diff --git a/apps/backend/internal/github/personal_auth_service_test.go b/apps/backend/internal/github/personal_auth_service_test.go new file mode 100644 index 0000000000..4a97404207 --- /dev/null +++ b/apps/backend/internal/github/personal_auth_service_test.go @@ -0,0 +1,681 @@ +package github + +import ( + "context" + "errors" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type personalAuthMemoryRepository struct { + mu sync.Mutex + workspace *WorkspaceConnection + connection *UserConnection + tokens GitHubOAuthTokens + replaceErr error + invalidated bool + version int64 +} + +func (r *personalAuthMemoryRepository) GetWorkspaceConnection(_ context.Context, _ string) (*WorkspaceConnection, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.workspace, nil +} + +func (r *personalAuthMemoryRepository) GetUserConnection(_ context.Context, _, _ string) (*UserConnection, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.connection == nil { + return nil, nil + } + copy := *r.connection + return ©, nil +} + +func (r *personalAuthMemoryRepository) GetPersonalConnectionGeneration( + context.Context, + string, + string, +) (int64, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.currentGenerationLocked(), nil +} + +func (r *personalAuthMemoryRepository) currentGenerationLocked() int64 { + if r.version > 0 { + return r.version + } + return personalConnectionGeneration(r.connection) +} + +func (r *personalAuthMemoryRepository) GetPersonalTokens(_ context.Context, _, _ string) (GitHubOAuthTokens, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.tokens, nil +} + +func (r *personalAuthMemoryRepository) ReplacePersonalConnection( + _ context.Context, connection *UserConnection, tokens GitHubOAuthTokens, expectedGeneration int64, +) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.replaceErr != nil { + return r.replaceErr + } + if r.currentGenerationLocked() != expectedGeneration { + return ErrPersonalConnectionStale + } + copy := *connection + r.connection = © + r.tokens = tokens + r.version = connection.CredentialGeneration + return nil +} + +func (r *personalAuthMemoryRepository) ReplacePersonalConnectionForFlow( + _ context.Context, + connection *UserConnection, + tokens GitHubOAuthTokens, + expectedGeneration int64, + expectedWorkspace WorkspaceConnectionExpectation, +) error { + r.mu.Lock() + defer r.mu.Unlock() + if !matchesWorkspaceConnectionExpectation(r.workspace, expectedWorkspace) || + r.currentGenerationLocked() != expectedGeneration || + connection.CredentialGeneration != expectedGeneration+1 { + return ErrOAuthFlowStale + } + copy := *connection + r.connection = © + r.tokens = tokens + r.version = connection.CredentialGeneration + return nil +} + +func (r *personalAuthMemoryRepository) MarkPersonalConnectionInvalid( + _ context.Context, _, _ string, expectedGeneration int64, _ error, +) error { + r.mu.Lock() + defer r.mu.Unlock() + if personalConnectionGeneration(r.connection) != expectedGeneration { + return ErrPersonalConnectionStale + } + r.invalidated = true + if r.connection != nil { + r.connection.Status = ConnectionStatusInvalid + r.version = r.connection.CredentialGeneration + } + return nil +} + +func (r *personalAuthMemoryRepository) RevokePersonalConnection(_ context.Context, _, _ string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.version = r.currentGenerationLocked() + 1 + r.connection = nil + r.tokens = GitHubOAuthTokens{} + return nil +} + +func (r *personalAuthMemoryRepository) RevokePersonalConnectionIfUnchanged( + _ context.Context, + expected *UserConnection, +) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + if !sameUserConnectionVersion(r.connection, expected) { + return false, nil + } + r.version = r.currentGenerationLocked() + 1 + r.connection = nil + r.tokens = GitHubOAuthTokens{} + return true, nil +} + +func (r *personalAuthMemoryRepository) RevokeWorkspacePersonalConnections(_ context.Context, _ string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.version = r.currentGenerationLocked() + 1 + r.connection = nil + r.tokens = GitHubOAuthTokens{} + return nil +} + +type personalOAuthRemote struct { + fakeGitHubAppOAuth + refreshed GitHubOAuthTokens + refreshErr error + refreshArg string + refreshes atomic.Int32 + started chan struct{} + release chan struct{} +} + +func (r *personalOAuthRemote) RefreshUserToken( + ctx context.Context, refreshToken string, +) (GitHubOAuthTokens, error) { + r.refreshes.Add(1) + r.refreshArg = refreshToken + if r.started != nil { + select { + case r.started <- struct{}{}: + default: + } + } + if r.release != nil { + select { + case <-r.release: + case <-ctx.Done(): + return GitHubOAuthTokens{}, ctx.Err() + } + } + return r.refreshed, r.refreshErr +} + +func TestPersonalAuthStartUsesPKCEAndWorkspaceAppBoundary(t *testing.T) { + flowStore := &oauthFlowMemoryStore{} + flows := NewOAuthFlowManager(flowStore) + flows.random = strings.NewReader(strings.Repeat("g", oauthRandomBytes*2)) + repo := &personalAuthMemoryRepository{workspace: activeAppWorkspace("workspace-1", 42)} + service := NewPersonalAuthService( + PersonalAuthConfig{RegistrationID: "registration-test", ClientID: "Iv1.client", CallbackURL: "https://kandev.example/personal/callback"}, + flows, repo, &personalOAuthRemote{}, + ) + + started, err := service.Start(context.Background(), "workspace-1", "user-1") + if err != nil { + t.Fatalf("Start() error = %v", err) + } + parsed, err := url.Parse(started.URL) + if err != nil { + t.Fatalf("parse URL: %v", err) + } + query := parsed.Query() + if parsed.Path != "/login/oauth/authorize" || query.Get("code_challenge_method") != "S256" || + query.Get("code_challenge") == "" || query.Get("state") == "" { + t.Fatalf("personal authorization URL = %q", started.URL) + } +} + +func TestPersonalAuthCompleteAtomicallyStoresVerifiedIdentity(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + flowStore := &oauthFlowMemoryStore{} + flows := NewOAuthFlowManager(flowStore) + flows.now = func() time.Time { return now } + flows.random = strings.NewReader(strings.Repeat("h", oauthRandomBytes*2)) + repo := &personalAuthMemoryRepository{workspace: activeAppWorkspace("workspace-1", 42)} + refreshExpiry := now.Add(180 * 24 * time.Hour) + remote := &personalOAuthRemote{fakeGitHubAppOAuth: fakeGitHubAppOAuth{ + tokens: GitHubOAuthTokens{ + AccessToken: "access", RefreshToken: "refresh", AccessExpiresAt: now.Add(8 * time.Hour), + RefreshExpiresAt: &refreshExpiry, + }, + user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true, + }} + service := NewPersonalAuthService( + PersonalAuthConfig{RegistrationID: "registration-test", ClientID: "Iv1.client", CallbackURL: "https://kandev.example/personal/callback"}, + flows, repo, remote, + ) + service.now = func() time.Time { return now } + started, err := service.Start(context.Background(), "workspace-1", "user-1") + if err != nil { + t.Fatalf("Start() error = %v", err) + } + _, err = service.Complete(context.Background(), PersonalAuthCallback{ + State: started.State, Code: "oauth-code", + }) + if err != nil { + t.Fatalf("Complete() error = %v", err) + } + if repo.connection == nil || repo.connection.GitHubUserID != 11 || repo.connection.Login != "octocat" || + repo.connection.CredentialGeneration != 1 || repo.tokens.RefreshToken != "refresh" { + t.Fatalf("stored personal connection = %+v, tokens = %+v", repo.connection, repo.tokens) + } +} + +func TestPersonalAuthCompleteRejectsUserOutsideWorkspaceInstallation(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + flowStore := &oauthFlowMemoryStore{} + flows := NewOAuthFlowManager(flowStore) + flows.now = func() time.Time { return now } + flows.random = strings.NewReader(strings.Repeat("i", oauthRandomBytes*2)) + repo := &personalAuthMemoryRepository{workspace: activeAppWorkspace("workspace-1", 42)} + remote := &personalOAuthRemote{fakeGitHubAppOAuth: fakeGitHubAppOAuth{ + tokens: GitHubOAuthTokens{AccessToken: "access", AccessExpiresAt: now.Add(time.Hour)}, + user: GitHubOAuthUser{ID: 11, Login: "outsider"}, canAccess: false, + }} + service := NewPersonalAuthService( + PersonalAuthConfig{RegistrationID: "registration-test", ClientID: "Iv1.client", CallbackURL: "https://kandev.example/personal/callback"}, + flows, repo, remote, + ) + service.now = func() time.Time { return now } + started, err := service.Start(context.Background(), "workspace-1", "user-1") + if err != nil { + t.Fatalf("Start() error = %v", err) + } + _, err = service.Complete(context.Background(), PersonalAuthCallback{ + WorkspaceID: "workspace-1", UserID: "user-1", State: started.State, Code: "oauth-code", + }) + if !errors.Is(err, ErrInstallationAssociationUnverified) || repo.connection != nil { + t.Fatalf("Complete() error = %v, connection = %+v", err, repo.connection) + } +} + +func TestPersonalAuthCompleteCannotSurviveWorkspaceSwitchToPAT(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + flowStore := &oauthFlowMemoryStore{} + flows := NewOAuthFlowManager(flowStore) + flows.now = func() time.Time { return now } + flows.random = strings.NewReader(strings.Repeat("p", oauthRandomBytes*2)) + repo := &personalAuthMemoryRepository{workspace: activeAppWorkspace("workspace-1", 42)} + remote := &personalOAuthRemote{fakeGitHubAppOAuth: fakeGitHubAppOAuth{ + tokens: GitHubOAuthTokens{ + AccessToken: "access", RefreshToken: "refresh", AccessExpiresAt: now.Add(time.Hour), + }, + user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true, + }} + service := NewPersonalAuthService( + PersonalAuthConfig{RegistrationID: "registration-test", ClientID: "Iv1.client", CallbackURL: "https://kandev.example/personal/callback"}, + flows, repo, remote, + ) + service.now = func() time.Time { return now } + started, err := service.Start(context.Background(), "workspace-1", "user-1") + if err != nil { + t.Fatal(err) + } + repo.workspace = &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourcePAT, Login: "octocat", + Status: ConnectionStatusActive, CredentialGeneration: 2, + } + + _, err = service.Complete(context.Background(), PersonalAuthCallback{State: started.State, Code: "oauth-code"}) + if !errors.Is(err, ErrGitHubPersonalRequired) || repo.connection != nil || repo.tokens.AccessToken != "" { + t.Fatalf("Complete() error = %v, connection = %+v, tokens = %+v", err, repo.connection, repo.tokens) + } +} + +func TestPersonalAuthCompletionRacingPATSwitchLeavesNoPersonalConnection(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + flowStore := &oauthFlowMemoryStore{} + flows := NewOAuthFlowManager(flowStore) + flows.now = func() time.Time { return now } + flows.random = strings.NewReader(strings.Repeat("r", oauthRandomBytes*2)) + repo := &personalAuthMemoryRepository{workspace: activeAppWorkspace("workspace-1", 42)} + remote := &personalOAuthRemote{fakeGitHubAppOAuth: fakeGitHubAppOAuth{ + tokens: GitHubOAuthTokens{ + AccessToken: "access", RefreshToken: "refresh", AccessExpiresAt: now.Add(time.Hour), + }, + user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true, + exchangeStarted: make(chan struct{}, 1), releaseExchange: make(chan struct{}), + }} + service := NewPersonalAuthService( + PersonalAuthConfig{RegistrationID: "registration-test", ClientID: "Iv1.client", CallbackURL: "https://kandev.example/personal/callback"}, + flows, repo, remote, + ) + service.now = func() time.Time { return now } + var workspaceLock sync.Mutex + service.SetWorkspaceMutationLock(func(string) *sync.Mutex { return &workspaceLock }) + started, err := service.Start(context.Background(), "workspace-1", "user-1") + if err != nil { + t.Fatal(err) + } + completeDone := make(chan error, 1) + go func() { + _, completeErr := service.Complete( + context.Background(), PersonalAuthCallback{State: started.State, Code: "oauth-code"}, + ) + completeDone <- completeErr + }() + <-remote.exchangeStarted + patDone := make(chan struct{}) + go func() { + workspaceLock.Lock() + repo.mu.Lock() + repo.workspace = &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourcePAT, Login: "octocat", + Status: ConnectionStatusActive, CredentialGeneration: 2, + } + repo.connection = nil + repo.tokens = GitHubOAuthTokens{} + repo.mu.Unlock() + workspaceLock.Unlock() + close(patDone) + }() + close(remote.releaseExchange) + if err := <-completeDone; err != nil { + t.Fatalf("Complete() error = %v", err) + } + <-patDone + if repo.workspace.Source != ConnectionSourcePAT || repo.connection != nil || repo.tokens.AccessToken != "" { + t.Fatalf("workspace=%+v connection=%+v tokens=%+v", repo.workspace, repo.connection, repo.tokens) + } +} + +func TestPersonalAuthCompleteCannotOverwriteNewerReconnect(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + flowStore := &oauthFlowMemoryStore{} + flows := NewOAuthFlowManager(flowStore) + flows.now = func() time.Time { return now } + flows.random = strings.NewReader(strings.Repeat("q", oauthRandomBytes*2)) + repo := &personalAuthMemoryRepository{ + workspace: activeAppWorkspace("workspace-1", 42), + connection: &UserConnection{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test", + GitHubUserID: 11, Login: "old", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + }, + } + remote := &personalOAuthRemote{fakeGitHubAppOAuth: fakeGitHubAppOAuth{ + tokens: GitHubOAuthTokens{ + AccessToken: "stale", RefreshToken: "stale-refresh", AccessExpiresAt: now.Add(time.Hour), + }, + user: GitHubOAuthUser{ID: 11, Login: "old"}, canAccess: true, + }} + service := NewPersonalAuthService( + PersonalAuthConfig{RegistrationID: "registration-test", ClientID: "Iv1.client", CallbackURL: "https://kandev.example/personal/callback"}, + flows, repo, remote, + ) + service.now = func() time.Time { return now } + started, err := service.Start(context.Background(), "workspace-1", "user-1") + if err != nil { + t.Fatal(err) + } + if err := repo.RevokePersonalConnection(context.Background(), "workspace-1", "user-1"); err != nil { + t.Fatal(err) + } + reconnected := &UserConnection{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test", + GitHubUserID: 11, Login: "new", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(2 * time.Hour), CredentialGeneration: 3, + } + if err := repo.ReplacePersonalConnection(context.Background(), reconnected, GitHubOAuthTokens{ + AccessToken: "new", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(2 * time.Hour), + }, 2); err != nil { + t.Fatal(err) + } + + _, err = service.Complete(context.Background(), PersonalAuthCallback{State: started.State, Code: "oauth-code"}) + if !errors.Is(err, ErrOAuthFlowStale) || repo.connection.Login != "new" || repo.tokens.AccessToken != "new" { + t.Fatalf("Complete() error = %v, connection = %+v, tokens = %+v", err, repo.connection, repo.tokens) + } +} + +func TestPersonalAuthRefreshFailureDoesNotReplaceUsableSecrets(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + oldTokens := GitHubOAuthTokens{ + AccessToken: "old-access", RefreshToken: "old-refresh", AccessExpiresAt: now.Add(time.Minute), + } + repo := &personalAuthMemoryRepository{ + workspace: activeAppWorkspace("workspace-1", 42), + connection: &UserConnection{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test", + GitHubUserID: 11, Login: "octocat", + Status: ConnectionStatusActive, AccessExpiresAt: oldTokens.AccessExpiresAt, CredentialGeneration: 4, + }, + tokens: oldTokens, replaceErr: errors.New("atomic repository commit failed"), + } + remote := &personalOAuthRemote{ + fakeGitHubAppOAuth: fakeGitHubAppOAuth{user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true}, + refreshed: GitHubOAuthTokens{ + AccessToken: "new-access", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(8 * time.Hour), + }, + } + service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote) + service.now = func() time.Time { return now } + + _, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1") + if err == nil || repo.tokens.AccessToken != "old-access" || repo.connection.CredentialGeneration != 4 { + t.Fatalf("ResolvePersonalToken() error = %v, connection = %+v, tokens = %+v", err, repo.connection, repo.tokens) + } +} + +func TestPersonalAuthRefreshRevokesEffectiveAuthWhenInstallationAccessIsLost(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + repo := &personalAuthMemoryRepository{ + workspace: activeAppWorkspace("workspace-1", 42), + connection: &UserConnection{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test", + GitHubUserID: 11, Login: "octocat", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Minute), CredentialGeneration: 2, + }, + tokens: GitHubOAuthTokens{AccessToken: "old", RefreshToken: "refresh", AccessExpiresAt: now.Add(time.Minute)}, + } + remote := &personalOAuthRemote{ + fakeGitHubAppOAuth: fakeGitHubAppOAuth{user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: false}, + refreshed: GitHubOAuthTokens{AccessToken: "new", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(time.Hour)}, + } + service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote) + service.now = func() time.Time { return now } + _, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1") + if !errors.Is(err, ErrInstallationAssociationUnverified) || !repo.invalidated || repo.tokens.AccessToken != "old" { + t.Fatalf("ResolvePersonalToken() error = %v, invalidated = %v, tokens = %+v", err, repo.invalidated, repo.tokens) + } +} + +func TestPersonalAuthRefreshRejectsChangedGitHubUser(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + repo := &personalAuthMemoryRepository{ + workspace: activeAppWorkspace("workspace-1", 42), + connection: &UserConnection{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test", + GitHubUserID: 11, Login: "octocat", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Minute), CredentialGeneration: 2, + }, + tokens: GitHubOAuthTokens{AccessToken: "old", RefreshToken: "refresh", AccessExpiresAt: now.Add(time.Minute)}, + } + remote := &personalOAuthRemote{ + fakeGitHubAppOAuth: fakeGitHubAppOAuth{user: GitHubOAuthUser{ID: 99, Login: "attacker"}}, + refreshed: GitHubOAuthTokens{AccessToken: "new", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(time.Hour)}, + } + service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote) + service.now = func() time.Time { return now } + _, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1") + if !errors.Is(err, ErrPersonalIdentityChanged) || !repo.invalidated || repo.tokens.AccessToken != "old" { + t.Fatalf("ResolvePersonalToken() error = %v, invalidated = %v, tokens = %+v", err, repo.invalidated, repo.tokens) + } +} + +func TestPersonalAuthRefreshCoalescesConcurrentCallers(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + repo := expiringPersonalAuthRepository(now) + remote := &personalOAuthRemote{ + fakeGitHubAppOAuth: fakeGitHubAppOAuth{user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true}, + refreshed: GitHubOAuthTokens{ + AccessToken: "new", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(time.Hour), + }, + started: make(chan struct{}, 1), release: make(chan struct{}), + } + service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote) + service.now = func() time.Time { return now } + + type result struct { + token string + err error + } + results := make(chan result, 2) + resolve := func() { + token, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1") + results <- result{token: token, err: err} + } + go resolve() + <-remote.started + go resolve() + time.Sleep(20 * time.Millisecond) + close(remote.release) + + for range 2 { + got := <-results + if got.err != nil || got.token != "new" { + t.Fatalf("ResolvePersonalToken() = %q, %v", got.token, got.err) + } + } + if got := remote.refreshes.Load(); got != 1 { + t.Fatalf("RefreshUserToken calls = %d, want 1", got) + } +} + +func TestPersonalAuthRefreshCallerCancellationDoesNotCancelSharedRotation(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + repo := expiringPersonalAuthRepository(now) + remote := &personalOAuthRemote{ + fakeGitHubAppOAuth: fakeGitHubAppOAuth{user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true}, + refreshed: GitHubOAuthTokens{ + AccessToken: "new", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(time.Hour), + }, + started: make(chan struct{}, 1), release: make(chan struct{}), + } + service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote) + service.now = func() time.Time { return now } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, _, err := service.ResolvePersonalToken(ctx, "workspace-1", "user-1") + done <- err + }() + <-remote.started + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("canceled caller error = %v", err) + } + close(remote.release) + token, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1") + if err != nil || token != "new" { + t.Fatalf("ResolvePersonalToken() after canceled caller = %q, %v", token, err) + } +} + +func TestPersonalAuthTransientRefreshFailureDoesNotInvalidateConnection(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + repo := expiringPersonalAuthRepository(now) + remote := &personalOAuthRemote{refreshErr: errors.New("temporary network failure")} + service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote) + service.now = func() time.Time { return now } + + if _, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1"); err == nil { + t.Fatal("expected refresh failure") + } + if repo.invalidated || repo.connection.Status != ConnectionStatusActive { + t.Fatalf("transient failure invalidated connection: %+v", repo.connection) + } +} + +func TestPersonalAuthInvalidGrantRefreshFailureInvalidatesConnection(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + repo := expiringPersonalAuthRepository(now) + remote := &personalOAuthRemote{refreshErr: &GitHubOAuthError{Code: "invalid_grant"}} + service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote) + service.now = func() time.Time { return now } + + if _, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1"); err == nil { + t.Fatal("expected refresh failure") + } + if !repo.invalidated || repo.connection.Status != ConnectionStatusInvalid { + t.Fatalf("invalid grant did not invalidate connection: %+v", repo.connection) + } +} + +func TestPersonalAuthRevokeWinsRaceWithRefresh(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + repo := expiringPersonalAuthRepository(now) + remote := &personalOAuthRemote{ + fakeGitHubAppOAuth: fakeGitHubAppOAuth{user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true}, + refreshed: GitHubOAuthTokens{ + AccessToken: "new", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(time.Hour), + }, + started: make(chan struct{}, 1), release: make(chan struct{}), + } + service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote) + service.now = func() time.Time { return now } + + done := make(chan error, 1) + go func() { + _, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1") + done <- err + }() + <-remote.started + if err := service.Revoke(context.Background(), "workspace-1", "user-1"); err != nil { + t.Fatalf("Revoke() error = %v", err) + } + close(remote.release) + if err := <-done; !errors.Is(err, ErrPersonalConnectionStale) { + t.Fatalf("ResolvePersonalToken() error = %v, want stale connection", err) + } + repo.mu.Lock() + defer repo.mu.Unlock() + if repo.connection != nil || repo.tokens.AccessToken != "" || repo.tokens.RefreshToken != "" { + t.Fatalf("revoked connection was resurrected: connection=%+v tokens=%+v", repo.connection, repo.tokens) + } +} + +func TestPersonalAuthorizationLateRevokeDoesNotDeleteReconnect(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + current := &UserConnection{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test", + GitHubUserID: 11, Login: "octocat", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), + CredentialGeneration: 2, UpdatedAt: now.Add(time.Second), + } + repo := &personalAuthMemoryRepository{ + workspace: activeAppWorkspace("workspace-1", 42), connection: current, + tokens: GitHubOAuthTokens{ + AccessToken: "reconnected", RefreshToken: "refresh", AccessExpiresAt: now.Add(time.Hour), + }, + } + service := NewPersonalAuthService( + PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, + &personalOAuthRemote{fakeGitHubAppOAuth: fakeGitHubAppOAuth{ + user: GitHubOAuthUser{ID: 11, Login: "octocat"}, + }}, + ) + + revoked, err := service.ReconcileAuthorizationRevocation(context.Background(), current) + if err != nil || revoked || repo.connection == nil || repo.tokens.AccessToken != "reconnected" { + t.Fatalf("ReconcileAuthorizationRevocation() = %v, %v; connection=%+v tokens=%+v", + revoked, err, repo.connection, repo.tokens) + } +} + +func TestPersonalConnectionVersionIncludesAppRegistration(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + expected := &UserConnection{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-work", + GitHubUserID: 11, CredentialGeneration: 2, UpdatedAt: now, + } + current := *expected + current.AppRegistrationID = "registration-personal" + + if sameUserConnectionVersion(¤t, expected) { + t.Fatal("connections from different App registrations matched") + } +} + +func expiringPersonalAuthRepository(now time.Time) *personalAuthMemoryRepository { + return &personalAuthMemoryRepository{ + workspace: activeAppWorkspace("workspace-1", 42), + connection: &UserConnection{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test", + GitHubUserID: 11, Login: "octocat", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Minute), CredentialGeneration: 2, + }, + tokens: GitHubOAuthTokens{ + AccessToken: "old", RefreshToken: "refresh", AccessExpiresAt: now.Add(time.Minute), + }, + } +} + +func activeAppWorkspace(workspaceID string, installationID int64) *WorkspaceConnection { + return &WorkspaceConnection{ + WorkspaceID: workspaceID, Source: ConnectionSourceGitHubAppInstallation, + GitHubHost: defaultGitHubHost, InstallationID: &installationID, + AppRegistrationID: "registration-test", + InstallationAccountLogin: "acme", InstallationAccountType: "Organization", + Status: ConnectionStatusActive, CredentialGeneration: 1, + } +} diff --git a/apps/backend/internal/github/personal_connection_repository.go b/apps/backend/internal/github/personal_connection_repository.go new file mode 100644 index 0000000000..5d643935d8 --- /dev/null +++ b/apps/backend/internal/github/personal_connection_repository.go @@ -0,0 +1,453 @@ +package github + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" +) + +const ( + personalAccessTokenSecretName = "GitHub personal access token" + personalRefreshTokenSecretName = "GitHub personal refresh token" +) + +// StorePersonalConnectionRepository provides the compensation boundary needed +// to keep encrypted OAuth tokens and their metadata consistent even though the +// two stores do not share a database transaction. +type StorePersonalConnectionRepository struct { + store *Store + secrets ConnectionSecretStore + + mu sync.Mutex +} + +func NewStorePersonalConnectionRepository( + store *Store, + secrets ConnectionSecretStore, +) *StorePersonalConnectionRepository { + return &StorePersonalConnectionRepository{store: store, secrets: secrets} +} + +func (r *StorePersonalConnectionRepository) GetWorkspaceConnection( + ctx context.Context, + workspaceID string, +) (*WorkspaceConnection, error) { + return r.store.GetWorkspaceConnection(ctx, workspaceID) +} + +func (r *StorePersonalConnectionRepository) GetUserConnection( + ctx context.Context, + workspaceID, userID string, +) (*UserConnection, error) { + return r.store.GetUserConnection(ctx, workspaceID, userID) +} + +func (r *StorePersonalConnectionRepository) GetPersonalConnectionGeneration( + ctx context.Context, + workspaceID, userID string, +) (int64, error) { + return r.store.GetUserConnectionGeneration(ctx, workspaceID, userID) +} + +func (r *StorePersonalConnectionRepository) GetPersonalTokens( + ctx context.Context, + workspaceID, userID string, +) (GitHubOAuthTokens, error) { + connection, err := r.store.GetUserConnection(ctx, workspaceID, userID) + if err != nil { + return GitHubOAuthTokens{}, err + } + if connection == nil { + return GitHubOAuthTokens{}, ErrGitHubPersonalRequired + } + access, err := r.secrets.Reveal(ctx, UserAccessTokenSecretKey(workspaceID, userID)) + if err != nil { + return GitHubOAuthTokens{}, err + } + refresh, _, err := revealOptionalSecret(ctx, r.secrets, UserRefreshTokenSecretKey(workspaceID, userID)) + if err != nil { + return GitHubOAuthTokens{}, err + } + return GitHubOAuthTokens{ + AccessToken: access, + RefreshToken: refresh, + AccessExpiresAt: connection.AccessExpiresAt, + RefreshExpiresAt: connection.RefreshExpiresAt, + }, nil +} + +func (r *StorePersonalConnectionRepository) ReplacePersonalConnection( + ctx context.Context, + connection *UserConnection, + tokens GitHubOAuthTokens, + expectedGeneration int64, +) error { + if r == nil || r.store == nil || r.secrets == nil || connection == nil { + return errors.New("personal GitHub connection repository is not configured") + } + r.mu.Lock() + defer r.mu.Unlock() + return r.replacePersonalConnectionLocked(ctx, connection, tokens, expectedGeneration) +} + +func (r *StorePersonalConnectionRepository) replacePersonalConnectionLocked( + ctx context.Context, + connection *UserConnection, + tokens GitHubOAuthTokens, + expectedGeneration int64, +) error { + if strings.TrimSpace(tokens.AccessToken) == "" || strings.TrimSpace(tokens.RefreshToken) == "" { + return ErrPersonalTokenInvalid + } + oldConnection, err := r.store.GetUserConnection(ctx, connection.WorkspaceID, connection.UserID) + if err != nil { + return err + } + currentGeneration, err := r.store.GetUserConnectionGeneration(ctx, connection.WorkspaceID, connection.UserID) + if err != nil { + return err + } + if currentGeneration != expectedGeneration || + connection.CredentialGeneration != expectedGeneration+1 { + return ErrPersonalConnectionStale + } + accessKey := UserAccessTokenSecretKey(connection.WorkspaceID, connection.UserID) + refreshKey := UserRefreshTokenSecretKey(connection.WorkspaceID, connection.UserID) + oldAccess, hadAccess, err := revealOptionalSecret(ctx, r.secrets, accessKey) + if err != nil { + return err + } + oldRefresh, hadRefresh, err := revealOptionalSecret(ctx, r.secrets, refreshKey) + if err != nil { + return err + } + if err := r.secrets.Set(ctx, accessKey, personalAccessTokenSecretName, tokens.AccessToken); err != nil { + return err + } + if err := r.secrets.Set(ctx, refreshKey, personalRefreshTokenSecretName, tokens.RefreshToken); err != nil { + return errors.Join(err, r.restoreSecret(ctx, accessKey, personalAccessTokenSecretName, oldAccess, hadAccess)) + } + if err := r.store.UpsertUserConnection(ctx, connection); err != nil { + return errors.Join( + err, + r.restoreSecret(ctx, accessKey, personalAccessTokenSecretName, oldAccess, hadAccess), + r.restoreSecret(ctx, refreshKey, personalRefreshTokenSecretName, oldRefresh, hadRefresh), + r.restoreUserConnection(ctx, oldConnection, connection.WorkspaceID, connection.UserID), + ) + } + return nil +} + +func (r *StorePersonalConnectionRepository) ReplacePersonalConnectionForFlow( + ctx context.Context, + connection *UserConnection, + tokens GitHubOAuthTokens, + expectedGeneration int64, + expectedWorkspace WorkspaceConnectionExpectation, +) error { + if r == nil || r.store == nil || r.secrets == nil || connection == nil { + return errors.New("personal GitHub connection repository is not configured") + } + r.mu.Lock() + defer r.mu.Unlock() + workspace, err := r.store.GetWorkspaceConnection(ctx, connection.WorkspaceID) + if err != nil { + return err + } + if !matchesWorkspaceConnectionExpectation(workspace, expectedWorkspace) { + return ErrOAuthFlowStale + } + return r.replacePersonalConnectionLocked(ctx, connection, tokens, expectedGeneration) +} + +func (r *StorePersonalConnectionRepository) MarkPersonalConnectionInvalid( + ctx context.Context, + workspaceID, userID string, + expectedGeneration int64, + reason error, +) error { + r.mu.Lock() + defer r.mu.Unlock() + + connection, err := r.store.GetUserConnection(ctx, workspaceID, userID) + if err != nil || connection == nil { + return err + } + if connection.CredentialGeneration != expectedGeneration || connection.Status != ConnectionStatusActive { + return ErrPersonalConnectionStale + } + connection.Status = ConnectionStatusInvalid + connection.CredentialGeneration++ + connection.LastError = truncateConnectionError(reason) + return r.store.UpsertUserConnection(ctx, connection) +} + +func (r *StorePersonalConnectionRepository) RevokePersonalConnection( + ctx context.Context, + workspaceID, userID string, +) error { + if r == nil || r.store == nil || r.secrets == nil { + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + + connection, err := r.store.GetUserConnection(ctx, workspaceID, userID) + if err != nil { + return err + } + return r.revokePersonalConnectionLocked(ctx, connection, workspaceID, userID) +} + +func (r *StorePersonalConnectionRepository) RevokePersonalConnectionIfUnchanged( + ctx context.Context, + expected *UserConnection, +) (bool, error) { + if r == nil || r.store == nil || r.secrets == nil || expected == nil { + return false, nil + } + r.mu.Lock() + defer r.mu.Unlock() + current, err := r.store.GetUserConnection(ctx, expected.WorkspaceID, expected.UserID) + if err != nil || !sameUserConnectionVersion(current, expected) { + return false, err + } + if err := r.revokePersonalConnectionLocked( + ctx, current, expected.WorkspaceID, expected.UserID, + ); err != nil { + return false, err + } + return true, nil +} + +func (r *StorePersonalConnectionRepository) revokePersonalConnectionLocked( + ctx context.Context, + connection *UserConnection, + workspaceID, userID string, +) error { + accessKey := UserAccessTokenSecretKey(workspaceID, userID) + refreshKey := UserRefreshTokenSecretKey(workspaceID, userID) + oldAccess, hadAccess, err := revealOptionalSecret(ctx, r.secrets, accessKey) + if err != nil { + return err + } + oldRefresh, hadRefresh, err := revealOptionalSecret(ctx, r.secrets, refreshKey) + if err != nil { + return err + } + if err := deleteOptionalSecret(ctx, r.secrets, accessKey); err != nil { + return err + } + if err := deleteOptionalSecret(ctx, r.secrets, refreshKey); err != nil { + return errors.Join(err, r.restoreSecret(ctx, accessKey, personalAccessTokenSecretName, oldAccess, hadAccess)) + } + if err := r.store.DeleteUserConnection(ctx, workspaceID, userID); err != nil { + return errors.Join( + err, + r.restoreSecret(ctx, accessKey, personalAccessTokenSecretName, oldAccess, hadAccess), + r.restoreSecret(ctx, refreshKey, personalRefreshTokenSecretName, oldRefresh, hadRefresh), + r.restoreUserConnection(ctx, connection, workspaceID, userID), + ) + } + return nil +} + +type personalConnectionSnapshot struct { + connection *UserConnection + access, refresh string + hadAccess, hadRefresh bool +} + +// RevokeWorkspacePersonalConnections removes every App user identity in a +// workspace. Secret deletion is compensated before metadata is committed so +// callers never observe a partially revoked set of users. +func (r *StorePersonalConnectionRepository) RevokeWorkspacePersonalConnections( + ctx context.Context, + workspaceID string, +) error { + return r.TransitionWorkspacePersonalConnections(ctx, workspaceID, func() error { return nil }) +} + +// TransitionWorkspacePersonalConnections revokes a workspace's personal App +// identities and runs the workspace mutation under the same compensation +// boundary. A failed mutation restores both metadata and encrypted tokens. +func (r *StorePersonalConnectionRepository) TransitionWorkspacePersonalConnections( + ctx context.Context, + workspaceID string, + mutation func() error, +) error { + if r == nil || r.store == nil || r.secrets == nil { + return mutation() + } + r.mu.Lock() + defer r.mu.Unlock() + + connections, err := r.store.ListUserConnectionsByWorkspace(ctx, workspaceID) + if err != nil { + return err + } + if len(connections) == 0 { + return mutation() + } + snapshots, err := r.loadWorkspacePersonalSnapshots(ctx, connections) + if err != nil { + return err + } + if err := r.deleteWorkspacePersonalSecrets(ctx, snapshots); err != nil { + return errors.Join(err, r.restoreWorkspacePersonalSnapshots(ctx, snapshots)) + } + if err := r.store.DeleteUserConnectionsByWorkspace(ctx, workspaceID); err != nil { + return errors.Join(err, r.restoreWorkspacePersonalSnapshots(ctx, snapshots)) + } + if err := mutation(); err != nil { + return errors.Join(err, r.restoreWorkspacePersonalSnapshots(ctx, snapshots)) + } + return nil +} + +func (r *StorePersonalConnectionRepository) loadWorkspacePersonalSnapshots( + ctx context.Context, + connections []*UserConnection, +) ([]personalConnectionSnapshot, error) { + snapshots := make([]personalConnectionSnapshot, 0, len(connections)) + for _, connection := range connections { + access, hadAccess, err := revealOptionalSecret( + ctx, r.secrets, UserAccessTokenSecretKey(connection.WorkspaceID, connection.UserID), + ) + if err != nil { + return nil, err + } + refresh, hadRefresh, err := revealOptionalSecret( + ctx, r.secrets, UserRefreshTokenSecretKey(connection.WorkspaceID, connection.UserID), + ) + if err != nil { + return nil, err + } + snapshots = append(snapshots, personalConnectionSnapshot{ + connection: connection, access: access, refresh: refresh, + hadAccess: hadAccess, hadRefresh: hadRefresh, + }) + } + return snapshots, nil +} + +func (r *StorePersonalConnectionRepository) deleteWorkspacePersonalSecrets( + ctx context.Context, + snapshots []personalConnectionSnapshot, +) error { + for _, snapshot := range snapshots { + workspaceID, userID := snapshot.connection.WorkspaceID, snapshot.connection.UserID + if err := deleteOptionalSecret(ctx, r.secrets, UserAccessTokenSecretKey(workspaceID, userID)); err != nil { + return err + } + if err := deleteOptionalSecret(ctx, r.secrets, UserRefreshTokenSecretKey(workspaceID, userID)); err != nil { + return err + } + } + return nil +} + +func (r *StorePersonalConnectionRepository) restoreWorkspacePersonalSnapshots( + ctx context.Context, + snapshots []personalConnectionSnapshot, +) error { + var restoreErr error + for _, snapshot := range snapshots { + workspaceID, userID := snapshot.connection.WorkspaceID, snapshot.connection.UserID + restoreErr = errors.Join(restoreErr, + r.restorePersonalConnectionMetadata(ctx, snapshot.connection), + r.restoreSecret(ctx, UserAccessTokenSecretKey(workspaceID, userID), + personalAccessTokenSecretName, snapshot.access, snapshot.hadAccess), + r.restoreSecret(ctx, UserRefreshTokenSecretKey(workspaceID, userID), + personalRefreshTokenSecretName, snapshot.refresh, snapshot.hadRefresh), + ) + } + return restoreErr +} + +func (r *StorePersonalConnectionRepository) restorePersonalConnectionMetadata( + ctx context.Context, + connection *UserConnection, +) error { + currentGeneration, err := r.store.GetUserConnectionGeneration( + ctx, connection.WorkspaceID, connection.UserID, + ) + if err != nil { + return err + } + restored := *connection + if currentGeneration > restored.CredentialGeneration { + restored.CredentialGeneration = currentGeneration + } + return r.store.UpsertUserConnection(ctx, &restored) +} + +func (r *StorePersonalConnectionRepository) restoreSecret( + ctx context.Context, + key, name, value string, + existed bool, +) error { + if existed { + return r.secrets.Set(ctx, key, name, value) + } + return deleteOptionalSecret(ctx, r.secrets, key) +} + +func (r *StorePersonalConnectionRepository) restoreUserConnection( + ctx context.Context, + connection *UserConnection, + workspaceID, userID string, +) error { + if connection == nil { + return r.store.DeleteUserConnection(ctx, workspaceID, userID) + } + return r.store.UpsertUserConnection(ctx, connection) +} + +func truncateConnectionError(reason error) string { + if reason == nil { + return "" + } + message := reason.Error() + if len(message) > 512 { + return message[:512] + } + return message +} + +type personalAuthCredentialProvider struct { + service *PersonalAuthService +} + +func (p *personalAuthCredentialProvider) ResolveUser( + ctx context.Context, + connection *UserConnection, + _ ResolveCredentialRequest, +) (*ResolvedCredential, error) { + if p == nil || p.service == nil || connection == nil { + return nil, ErrGitHubPersonalRequired + } + token, current, err := p.service.ResolvePersonalToken(ctx, connection.WorkspaceID, connection.UserID) + if err != nil { + return nil, err + } + if current == nil || current.GitHubUserID != connection.GitHubUserID { + return nil, fmt.Errorf("%w: personal connection changed during resolution", ErrGitHubConnectionInvalid) + } + tracker := NewRateTracker(nil, nil) + client := NewAppUserTokenClient(token, current.GitHubUserID, current.Login).WithRateTracker(tracker) + return &ResolvedCredential{ + Client: client, + Principal: AuthPrincipal{ + Kind: AuthPrincipalHuman, + Source: ConnectionSourceGitHubAppUser, + Login: current.Login, + }, + CredentialGeneration: current.CredentialGeneration, + ExpiresAt: current.AccessExpiresAt, + RateTracker: tracker, + credential: token, + }, nil +} diff --git a/apps/backend/internal/github/personal_connection_repository_test.go b/apps/backend/internal/github/personal_connection_repository_test.go new file mode 100644 index 0000000000..d4750a4285 --- /dev/null +++ b/apps/backend/internal/github/personal_connection_repository_test.go @@ -0,0 +1,271 @@ +package github + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestStorePersonalConnectionRepositoryRoundTripAndRevoke(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + seedPersonalAppConnection(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + repo := NewStorePersonalConnectionRepository(store, secrets) + now := time.Now().UTC() + refreshExpiry := now.Add(30 * 24 * time.Hour) + connection := &UserConnection{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-personal-test", + GitHubUserID: 42, Login: "octocat", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(8 * time.Hour), + RefreshExpiresAt: &refreshExpiry, CredentialGeneration: 1, + } + tokens := GitHubOAuthTokens{ + AccessToken: "access", RefreshToken: "refresh", + AccessExpiresAt: connection.AccessExpiresAt, RefreshExpiresAt: &refreshExpiry, + } + if err := repo.ReplacePersonalConnection(context.Background(), connection, tokens, 0); err != nil { + t.Fatalf("ReplacePersonalConnection: %v", err) + } + got, err := repo.GetPersonalTokens(context.Background(), "workspace-1", "user-1") + if err != nil { + t.Fatalf("GetPersonalTokens: %v", err) + } + if got.AccessToken != "access" || got.RefreshToken != "refresh" { + t.Fatalf("tokens = %+v", got) + } + if err := repo.RevokePersonalConnection(context.Background(), "workspace-1", "user-1"); err != nil { + t.Fatalf("RevokePersonalConnection: %v", err) + } + stored, err := store.GetUserConnection(context.Background(), "workspace-1", "user-1") + if err != nil || stored != nil { + t.Fatalf("stored connection after revoke = %+v, err %v", stored, err) + } + if len(secrets.values) != 0 { + t.Fatalf("personal secrets remain after revoke: %#v", secrets.values) + } +} + +func TestStorePersonalConnectionRepositoryCompensatesPartialSecretRotation(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + seedPersonalAppConnection(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + repo := NewStorePersonalConnectionRepository(store, secrets) + now := time.Now().UTC() + oldConnection := &UserConnection{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-personal-test", + GitHubUserID: 42, Login: "old", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + } + if err := repo.ReplacePersonalConnection(context.Background(), oldConnection, GitHubOAuthTokens{ + AccessToken: "old-access", RefreshToken: "old-refresh", AccessExpiresAt: oldConnection.AccessExpiresAt, + }, 0); err != nil { + t.Fatal(err) + } + secrets.setErr = errors.New("refresh write failed") + secrets.setErrKey = UserRefreshTokenSecretKey("workspace-1", "user-1") + replacement := *oldConnection + replacement.Login = "new" + replacement.CredentialGeneration = 2 + if err := repo.ReplacePersonalConnection(context.Background(), &replacement, GitHubOAuthTokens{ + AccessToken: "new-access", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(2 * time.Hour), + }, 1); err == nil { + t.Fatal("expected rotation failure") + } + if got := secrets.values[UserAccessTokenSecretKey("workspace-1", "user-1")]; got != "old-access" { + t.Fatalf("access token after compensation = %q", got) + } + stored, err := store.GetUserConnection(context.Background(), "workspace-1", "user-1") + if err != nil || stored.Login != "old" || stored.CredentialGeneration != 1 { + t.Fatalf("metadata after compensation = %+v, err %v", stored, err) + } +} + +func TestStorePersonalConnectionRepositoryRejectsStaleReplacementAfterRevoke(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + seedPersonalAppConnection(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + repo := NewStorePersonalConnectionRepository(store, secrets) + now := time.Now().UTC() + connection := &UserConnection{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-personal-test", + GitHubUserID: 42, Login: "octocat", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Minute), CredentialGeneration: 1, + } + oldTokens := GitHubOAuthTokens{ + AccessToken: "old-access", RefreshToken: "old-refresh", AccessExpiresAt: connection.AccessExpiresAt, + } + if err := repo.ReplacePersonalConnection(context.Background(), connection, oldTokens, 0); err != nil { + t.Fatal(err) + } + if err := repo.RevokePersonalConnection(context.Background(), "workspace-1", "user-1"); err != nil { + t.Fatal(err) + } + replacement := *connection + replacement.CredentialGeneration = 2 + err := repo.ReplacePersonalConnection(context.Background(), &replacement, GitHubOAuthTokens{ + AccessToken: "new-access", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(time.Hour), + }, 1) + if !errors.Is(err, ErrPersonalConnectionStale) { + t.Fatalf("ReplacePersonalConnection() error = %v, want stale connection", err) + } + if len(secrets.values) != 0 { + t.Fatalf("stale replacement restored secrets: %#v", secrets.values) + } +} + +func TestStorePersonalConnectionRepositoryLateRevokePreservesReplacement(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + seedPersonalAppConnection(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + repo := NewStorePersonalConnectionRepository(store, secrets) + now := time.Now().UTC() + first := &UserConnection{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-personal-test", + GitHubUserID: 42, Login: "octocat", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + } + if err := repo.ReplacePersonalConnection(context.Background(), first, GitHubOAuthTokens{ + AccessToken: "old", RefreshToken: "old-refresh", AccessExpiresAt: first.AccessExpiresAt, + }, 0); err != nil { + t.Fatal(err) + } + snapshot, err := repo.GetUserConnection(context.Background(), "workspace-1", "user-1") + if err != nil { + t.Fatal(err) + } + replacement := *snapshot + replacement.CredentialGeneration++ + replacement.AccessExpiresAt = now.Add(2 * time.Hour) + if err := repo.ReplacePersonalConnection(context.Background(), &replacement, GitHubOAuthTokens{ + AccessToken: "new", RefreshToken: "new-refresh", AccessExpiresAt: replacement.AccessExpiresAt, + }, snapshot.CredentialGeneration); err != nil { + t.Fatal(err) + } + revoked, err := repo.RevokePersonalConnectionIfUnchanged(context.Background(), snapshot) + if err != nil { + t.Fatal(err) + } + tokens, err := repo.GetPersonalTokens(context.Background(), "workspace-1", "user-1") + if err != nil { + t.Fatal(err) + } + if revoked || tokens.AccessToken != "new" { + t.Fatalf("revoked = %v, tokens = %+v", revoked, tokens) + } +} + +func TestStorePersonalConnectionRepositoryBulkRevokeCompensatesSecretDeleteFailure(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + seedPersonalAppConnection(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + repo := NewStorePersonalConnectionRepository(store, secrets) + now := time.Now().UTC() + for index, userID := range []string{"user-1", "user-2"} { + connection := &UserConnection{ + WorkspaceID: "workspace-1", UserID: userID, AppRegistrationID: "registration-personal-test", + GitHubUserID: int64(index + 1), Login: userID, + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + } + if err := repo.ReplacePersonalConnection(context.Background(), connection, GitHubOAuthTokens{ + AccessToken: "access-" + userID, RefreshToken: "refresh-" + userID, + AccessExpiresAt: connection.AccessExpiresAt, + }, 0); err != nil { + t.Fatal(err) + } + } + secrets.deleteErr = errors.New("delete failed") + secrets.deleteErrKey = UserRefreshTokenSecretKey("workspace-1", "user-2") + + if err := repo.RevokeWorkspacePersonalConnections(context.Background(), "workspace-1"); err == nil { + t.Fatal("expected bulk revoke failure") + } + for _, userID := range []string{"user-1", "user-2"} { + connection, err := store.GetUserConnection(context.Background(), "workspace-1", userID) + if err != nil || connection == nil { + t.Fatalf("connection %s after compensation = %+v, err %v", userID, connection, err) + } + if got := secrets.values[UserAccessTokenSecretKey("workspace-1", userID)]; got != "access-"+userID { + t.Fatalf("access secret for %s after compensation = %q", userID, got) + } + if got := secrets.values[UserRefreshTokenSecretKey("workspace-1", userID)]; got != "refresh-"+userID { + t.Fatalf("refresh secret for %s after compensation = %q", userID, got) + } + } +} + +func TestStorePersonalConnectionRepositoryFailedTransitionAllowsLaterRotation(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + seedPersonalAppConnection(t, store, "workspace-1") + secrets := newFakeConnectionSecrets() + repo := NewStorePersonalConnectionRepository(store, secrets) + ctx := context.Background() + now := time.Now().UTC() + connection := &UserConnection{ + WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-personal-test", + GitHubUserID: 42, Login: "octocat", Status: ConnectionStatusActive, + AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + } + if err := repo.ReplacePersonalConnection(ctx, connection, GitHubOAuthTokens{ + AccessToken: "old-access", RefreshToken: "old-refresh", AccessExpiresAt: connection.AccessExpiresAt, + }, 0); err != nil { + t.Fatal(err) + } + + transitionErr := errors.New("workspace credential transition failed") + if err := repo.TransitionWorkspacePersonalConnections(ctx, "workspace-1", func() error { + return transitionErr + }); !errors.Is(err, transitionErr) { + t.Fatalf("TransitionWorkspacePersonalConnections() error = %v, want %v", err, transitionErr) + } + restored, err := repo.GetUserConnection(ctx, "workspace-1", "user-1") + if err != nil || restored == nil { + t.Fatalf("restored connection = %+v, err %v", restored, err) + } + currentGeneration, err := repo.GetPersonalConnectionGeneration(ctx, "workspace-1", "user-1") + if err != nil { + t.Fatal(err) + } + if restored.CredentialGeneration != currentGeneration { + t.Fatalf("restored generation = %d, current generation = %d", restored.CredentialGeneration, currentGeneration) + } + + replacement := *restored + replacement.CredentialGeneration++ + replacement.AccessExpiresAt = now.Add(2 * time.Hour) + if err := repo.ReplacePersonalConnection(ctx, &replacement, GitHubOAuthTokens{ + AccessToken: "new-access", RefreshToken: "new-refresh", AccessExpiresAt: replacement.AccessExpiresAt, + }, restored.CredentialGeneration); err != nil { + t.Fatalf("ReplacePersonalConnection() after compensated transition: %v", err) + } + tokens, err := repo.GetPersonalTokens(ctx, "workspace-1", "user-1") + if err != nil || tokens.AccessToken != "new-access" || tokens.RefreshToken != "new-refresh" { + t.Fatalf("rotated tokens = %+v, err %v", tokens, err) + } +} + +func seedPersonalAppConnection(t *testing.T, store *Store, workspaceID string) { + t.Helper() + ctx := context.Background() + registration := newAppRegistration( + "registration-personal-test", 987, "Personal test", time.Now().UTC(), + ) + if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil { + t.Fatalf("seed App registration: %v", err) + } + installationID := int64(42) + if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ + WorkspaceID: workspaceID, Source: ConnectionSourceGitHubAppInstallation, + GitHubHost: "github.com", InstallationID: &installationID, + InstallationAccountLogin: "acme", InstallationAccountType: "Organization", + AppRegistrationID: registration.ID, Status: ConnectionStatusActive, + }); err != nil { + t.Fatalf("seed workspace App connection: %v", err) + } +} diff --git a/apps/backend/internal/github/poller.go b/apps/backend/internal/github/poller.go index c8f931b0db..20e33c403b 100644 --- a/apps/backend/internal/github/poller.go +++ b/apps/backend/internal/github/poller.go @@ -70,6 +70,7 @@ func (p *Poller) waitForRateLimit(ctx context.Context, resource Resource, loop s // RepositoryID scopes multi-repo tasks: each repo is a separate watch row. // Empty for legacy single-repo tasks. type TaskBranchInfo struct { + WorkspaceID string TaskID string SessionID string RepositoryID string @@ -193,10 +194,23 @@ func (p *Poller) checkPRWatches(ctx context.Context) { // publishing PRFeedback events for status changes and merge/close events // (the on-demand sync path intentionally doesn't publish these). func (p *Poller) tryBatchedPRWatchCheck(ctx context.Context, watches []*PRWatch) bool { - results, err := p.service.SyncWatchesBatched(ctx, watches) - if err != nil { - p.logger.Debug("batched PR watch check failed", zap.Error(err)) - return false + byWorkspace := make(map[string][]*PRWatch) + for _, watch := range watches { + if watch == nil || watch.WorkspaceID == "" { + p.logger.Warn("PR watch is missing workspace ownership", zap.String("watch_id", watchID(watch))) + return false + } + byWorkspace[watch.WorkspaceID] = append(byWorkspace[watch.WorkspaceID], watch) + } + results := make([]PRWatchSyncResult, 0, len(watches)) + for workspaceID, workspaceWatches := range byWorkspace { + workspaceResults, err := p.service.SyncWorkspaceWatchesBatched(ctx, workspaceID, workspaceWatches) + if err != nil { + p.logger.Debug("batched PR watch check failed", + zap.String("workspace_id", workspaceID), zap.Error(err)) + return false + } + results = append(results, workspaceResults...) } for _, r := range results { if !r.Found || r.Status == nil { @@ -228,6 +242,13 @@ func (p *Poller) tryBatchedPRWatchCheck(ctx context.Context, watches []*PRWatch) return true } +func watchID(watch *PRWatch) string { + if watch == nil { + return "" + } + return watch.ID +} + // splitPRWatches partitions watches into "we know the PR number" and "still // searching for one on this branch" buckets so each gets its own batched // query shape. Used by Service.SyncWatchesBatched. @@ -249,7 +270,7 @@ func (p *Poller) checkSinglePRWatch(ctx context.Context, watch *PRWatch) { return } - status, hasNew, err := p.service.CheckPRWatch(ctx, watch) + status, hasNew, err := p.service.CheckPRWatchForWorkspace(ctx, watch) if err != nil { p.logger.Debug("failed to check PR watch", zap.String("id", watch.ID), zap.Error(err)) @@ -295,11 +316,15 @@ func (p *Poller) checkSinglePRWatch(ctx context.Context, watch *PRWatch) { // detectPRForWatch searches GitHub for a PR on the watch's branch. // If found, updates the watch with the PR number and creates the TaskPR association. func (p *Poller) detectPRForWatch(ctx context.Context, watch *PRWatch) { - if p.service.client == nil { + if watch == nil || watch.WorkspaceID == "" { + p.logger.Warn("cannot detect PR for watch without workspace ownership", + zap.String("watch_id", watchID(watch))) return } - pr, err := p.service.client.FindPRByBranch(ctx, watch.Owner, watch.Repo, watch.Branch) + pr, err := p.service.FindPRByBranchForWorkspace( + ctx, watch.WorkspaceID, watch.Owner, watch.Repo, watch.Branch, + ) if err != nil { p.logger.Debug("failed to search for PR by branch", zap.String("watch_id", watch.ID), @@ -380,8 +405,8 @@ func (p *Poller) reconcileWatches(ctx context.Context) { return } for _, task := range tasks { - if _, ensureErr := p.service.EnsurePRWatch( - ctx, task.SessionID, task.TaskID, task.RepositoryID, task.Owner, task.Repo, task.Branch, + if _, ensureErr := p.service.EnsurePRWatchForWorkspace( + ctx, task.WorkspaceID, task.SessionID, task.TaskID, task.RepositoryID, task.Owner, task.Repo, task.Branch, ); ensureErr != nil { p.logger.Error("failed to ensure PR watch", zap.String("session_id", task.SessionID), zap.Error(ensureErr)) diff --git a/apps/backend/internal/github/poller_test.go b/apps/backend/internal/github/poller_test.go index 81126c9599..ce2cdcf804 100644 --- a/apps/backend/internal/github/poller_test.go +++ b/apps/backend/internal/github/poller_test.go @@ -52,6 +52,7 @@ func setupPollerTest(t *testing.T) (*Poller, *Service, *MockClient, *Store) { eventBus := bus.NewMemoryEventBus(log) svc := NewService(mockClient, "pat", nil, store, eventBus, log) + configureTestWorkspaceAuth(t, svc, mockClient, testWorkspaceID, "ws-1", "ws1") poller := NewPoller(svc, eventBus, log) return poller, svc, mockClient, store @@ -97,7 +98,7 @@ func TestCheckSinglePRWatch_MergedPR_SyncsThenResets(t *testing.T) { PRNumber: 42, Branch: "feature-branch", } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatalf("create PR watch: %v", err) } @@ -179,7 +180,7 @@ func TestCheckSinglePRWatch_OpenPR_SyncsOnChange(t *testing.T) { Branch: "open-branch", LastCheckStatus: "pending", // different from "success" -> hasNew=true } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatalf("create PR watch: %v", err) } @@ -254,8 +255,8 @@ func TestReconcileWatches_CreatesWatchesForTasks(t *testing.T) { prov := &mockTaskBranchProvider{ tasks: []TaskBranchInfo{ - {TaskID: "t1", SessionID: "s1", Owner: "myorg", Repo: "myrepo", Branch: "feature-a"}, - {TaskID: "t2", SessionID: "s2", Owner: "myorg", Repo: "myrepo", Branch: "feature-b"}, + {WorkspaceID: testWorkspaceID, TaskID: "t1", SessionID: "s1", Owner: "myorg", Repo: "myrepo", Branch: "feature-a"}, + {WorkspaceID: testWorkspaceID, TaskID: "t2", SessionID: "s2", Owner: "myorg", Repo: "myrepo", Branch: "feature-b"}, }, } poller.SetTaskBranchProvider(prov) @@ -310,14 +311,14 @@ func TestReconcileWatches_SkipsExistingWatches(t *testing.T) { PRNumber: 0, Branch: "feature-a", } - if err := store.CreatePRWatch(ctx, existing); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(existing)); err != nil { t.Fatalf("create PR watch: %v", err) } prov := &mockTaskBranchProvider{ tasks: []TaskBranchInfo{ - {TaskID: "t1", SessionID: "s1", Owner: "myorg", Repo: "myrepo", Branch: "feature-a"}, - {TaskID: "t2", SessionID: "s2", Owner: "myorg", Repo: "myrepo", Branch: "feature-b"}, + {WorkspaceID: testWorkspaceID, TaskID: "t1", SessionID: "s1", Owner: "myorg", Repo: "myrepo", Branch: "feature-a"}, + {WorkspaceID: testWorkspaceID, TaskID: "t2", SessionID: "s2", Owner: "myorg", Repo: "myrepo", Branch: "feature-b"}, }, } poller.SetTaskBranchProvider(prov) @@ -368,7 +369,7 @@ func TestCheckSinglePRWatch_OpenPR_NoChange_NoSync(t *testing.T) { Branch: "stable-branch", LastCheckStatus: "success", // same -> hasNew=false } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatalf("create PR watch: %v", err) } @@ -415,7 +416,7 @@ func TestRefreshStaleBranches_UpdatesBranchWhenChanged(t *testing.T) { PRNumber: 0, Branch: "old-branch", } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatalf("create PR watch: %v", err) } @@ -455,7 +456,7 @@ func TestRefreshStaleBranches_SkipsWhenBranchUnchanged(t *testing.T) { PRNumber: 0, Branch: "same-branch", } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatalf("create PR watch: %v", err) } @@ -488,7 +489,7 @@ func TestRefreshStaleBranches_SkipsWatchesWithPR(t *testing.T) { PRNumber: 42, Branch: "old-branch", } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatalf("create PR watch: %v", err) } @@ -521,7 +522,7 @@ func TestRefreshStaleBranches_SkipsWhenResolverReturnsEmpty(t *testing.T) { PRNumber: 0, Branch: "old-branch", } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatalf("create PR watch: %v", err) } @@ -588,6 +589,7 @@ func setupBatchedPollerTest(t *testing.T) (*Poller, *Service, *graphQLMockClient poller, svc, mockClient, store := setupPollerTest(t) wrapped := &graphQLMockClient{MockClient: mockClient} svc.client = wrapped + configureTestWorkspaceAuth(t, svc, wrapped, testWorkspaceID, "ws-1", "ws1") return poller, svc, wrapped, store } @@ -616,7 +618,7 @@ func TestTryBatchedPRWatchCheck_NumberedWatch_AppliesStatus(t *testing.T) { SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 42, Branch: "feat", LastCheckStatus: "pending", LastReviewState: "", } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatalf("create PR watch: %v", err) } taskPR := &TaskPR{ @@ -682,7 +684,7 @@ func TestTryBatchedPRWatchCheck_PublishesOnPRFeedbackWatermarkChange(t *testing. LastCheckStatus: "success", LastReviewState: "", } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatalf("create PR watch: %v", err) } if err := store.CreateTaskPR(ctx, &TaskPR{ @@ -761,7 +763,7 @@ func TestTriggerPRSyncAll_ReturnsPerWatchFallbackErrors(t *testing.T) { PRNumber: 42, Branch: "feat", } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatalf("create PR watch: %v", err) } @@ -795,7 +797,7 @@ func TestTriggerPRSyncAll_ReturnsPartialErrorWithSuccessfulPRs(t *testing.T) { {SessionID: "s1", TaskID: "t1", RepositoryID: "repo-live", Owner: "o", Repo: "r", PRNumber: 1, Branch: "feat-live"}, {SessionID: "s2", TaskID: "t1", RepositoryID: "repo-dead", Owner: "o", Repo: "r", PRNumber: 2, Branch: "feat-dead"}, } { - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatalf("create PR watch: %v", err) } } @@ -850,7 +852,7 @@ func TestTryBatchedPRWatchCheck_SearchingWatch_DetectsPR(t *testing.T) { watch := &PRWatch{ SessionID: "s2", TaskID: "t2", Owner: "o", Repo: "r", PRNumber: 0, Branch: "feat", } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatalf("create PR watch: %v", err) } @@ -874,7 +876,7 @@ func TestTryBatchedPRWatchCheck_FallsBackOnUnsupportedClient(t *testing.T) { ctx := context.Background() watch := &PRWatch{SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 1, Branch: "feat"} - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatalf("create PR watch: %v", err) } if poller.tryBatchedPRWatchCheck(ctx, []*PRWatch{watch}) { @@ -910,7 +912,7 @@ func TestSyncWatchesBatched_ManyWatches_TwoGraphQLCalls(t *testing.T) { for i := 0; i < 3; i++ { w := &PRWatch{SessionID: "n" + string(rune('a'+i)), TaskID: "tn" + string(rune('a'+i)), Owner: "o", Repo: "r", PRNumber: i + 1, Branch: "n"} - if err := store.CreatePRWatch(ctx, w); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(w)); err != nil { t.Fatalf("create numbered: %v", err) } watches = append(watches, w) @@ -918,7 +920,7 @@ func TestSyncWatchesBatched_ManyWatches_TwoGraphQLCalls(t *testing.T) { for i := 0; i < 4; i++ { w := &PRWatch{SessionID: "s" + string(rune('a'+i)), TaskID: "ts" + string(rune('a'+i)), Owner: "o", Repo: "r", PRNumber: 0, Branch: "s" + string(rune('a'+i))} - if err := store.CreatePRWatch(ctx, w); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(w)); err != nil { t.Fatalf("create searching: %v", err) } watches = append(watches, w) @@ -973,7 +975,7 @@ func TestSyncWatchesBatched_MergedPR_ResetsWatch(t *testing.T) { watch := &PRWatch{ SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 99, Branch: "feat", } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatalf("create PR watch: %v", err) } taskPR := &TaskPR{ @@ -1031,8 +1033,8 @@ func TestRefreshStaleWorkspaceWatches_CoalescesConcurrentCalls(t *testing.T) { // means refreshStaleWorkspaceWatches drives them through the branch // query path, which is what onExecute will block on. for _, id := range []string{"t1", "t2"} { - w := &PRWatch{SessionID: "sess-" + id, TaskID: id, Owner: "o", Repo: "r", PRNumber: 0, Branch: "br-" + id} - if err := store.CreatePRWatch(ctx, w); err != nil { + w := &PRWatch{WorkspaceID: "ws1", SessionID: "sess-" + id, TaskID: id, Owner: "o", Repo: "r", PRNumber: 0, Branch: "br-" + id} + if err := store.CreatePRWatch(ctx, withTestWorkspace(w)); err != nil { t.Fatalf("create watch: %v", err) } } @@ -1115,8 +1117,8 @@ func TestServiceStop_DrainsRefreshGoroutine(t *testing.T) { _, svc, gh, store := setupBatchedPollerTest(t) ctx := context.Background() - w := &PRWatch{SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 0, Branch: "br-1"} - if err := store.CreatePRWatch(ctx, w); err != nil { + w := &PRWatch{WorkspaceID: "ws1", SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 0, Branch: "br-1"} + if err := store.CreatePRWatch(ctx, withTestWorkspace(w)); err != nil { t.Fatalf("create watch: %v", err) } @@ -1210,6 +1212,21 @@ func TestSearchBucketExhausted(t *testing.T) { } } +func TestDetectPRForWatch_FailsClosedWithoutWorkspace(t *testing.T) { + poller, _, mockClient, _ := setupPollerTest(t) + + poller.detectPRForWatch(context.Background(), &PRWatch{ + ID: "legacy-watch", + Owner: "owner", + Repo: "repo", + Branch: "feature", + }) + + if got := mockClient.FindPRByBranchCallCount(); got != 0 { + t.Fatalf("FindPRByBranch calls = %d, want 0 for unowned watch", got) + } +} + // Regression: when the search bucket trips mid-cycle, checkReviewWatches must // stop iterating so the remaining watches don't issue doomed search requests // that deepen the secondary-limit penalty. diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index adf296fea0..df364c16ce 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -79,6 +79,16 @@ type SecretManager interface { Delete(ctx context.Context, id string) error } +// ConnectionSecretStore provides encrypted storage for internal GitHub +// credentials, including deterministic workspace keys and deployment bundles. +type ConnectionSecretStore interface { + Reveal(ctx context.Context, id string) (string, error) + Set(ctx context.Context, id, name, value string) error + Delete(ctx context.Context, id string) error + Exists(ctx context.Context, id string) (bool, error) + ListIDs(ctx context.Context) ([]string, error) +} + // PromptResolver resolves editable prompt content by name. type PromptResolver interface { ResolvePromptContent(ctx context.Context, name, fallback string) string @@ -86,16 +96,23 @@ type PromptResolver interface { // Service coordinates GitHub integration operations. type Service struct { - mu sync.Mutex - client Client - authMethod string - secrets SecretProvider - secretManager SecretManager - store *Store - eventBus bus.EventBus - logger *logger.Logger - taskDeleter TaskDeleter - taskIssueStore TaskIssueStore + mu sync.Mutex + connectionMutationLocks [64]sync.Mutex + client Client + authMethod string + secrets SecretProvider + secretManager SecretManager + connectionSecrets ConnectionSecretStore + personalConnections *StorePersonalConnectionRepository + resolver *CredentialResolver + appRegistrationRuntimes map[string]*githubAppRuntime + appRegistrationLifecycle *AppRegistrationLifecycleService + credentialBroker *CredentialBroker + store *Store + eventBus bus.EventBus + logger *logger.Logger + taskDeleter TaskDeleter + taskIssueStore TaskIssueStore // cascadeTaskDeleter is the cascade-delete entry point used by the // watch reset flow. It is distinct from taskDeleter (which only deletes // a single task by ID) because reset must walk the task tree and clean @@ -114,6 +131,10 @@ type Service struct { protectionCache *branchProtectionCache rateTracker *RateTracker promptResolver PromptResolver + tokenClientFactory func(string) Client + ghAccountLister func(context.Context) ([]GHAccount, error) + mockAuth *MockAuthState + workspaceAuthorizer func(context.Context, string) error // cleanupFailureMu guards cleanupFailureCounts; the cleanup loop is the // only writer but the global sweep + per-watch sweep can run concurrently @@ -143,28 +164,62 @@ type Service struct { stopOnce sync.Once } +// SetWorkspaceAuthorizer installs the workspace access boundary used before +// user-facing GitHub credential reads or mutations. +func (s *Service) SetWorkspaceAuthorizer(authorizer func(context.Context, string) error) { + if s != nil { + s.workspaceAuthorizer = authorizer + } +} + +func (s *Service) authorizeWorkspaceAccess(ctx context.Context, workspaceID string) error { + if s == nil || s.workspaceAuthorizer == nil { + return nil + } + return s.workspaceAuthorizer(ctx, workspaceID) +} + // NewService creates a new GitHub service. func NewService(client Client, authMethod string, secrets SecretProvider, store *Store, eventBus bus.EventBus, log *logger.Logger) *Service { stopCtx, stopCancel := context.WithCancel(context.Background()) - return &Service{ - client: client, - authMethod: authMethod, - secrets: secrets, - store: store, - eventBus: eventBus, - logger: log, - searchCache: newTTLCache(), - prStatusCache: newTTLCache(), - prFeedbackCache: newPRFeedbackCache(), - mergeMethodsCache: newMergeMethodsCache(), - accessibleReposCache: newAccessibleReposCache(), - repoErrorCache: newRepoErrorCache(), - protectionCache: newBranchProtectionCache(), - rateTracker: NewRateTracker(eventBus, log), - cleanupFailureCounts: make(map[string]int), - stopCtx: stopCtx, - stopCancel: stopCancel, + service := &Service{ + client: client, + authMethod: authMethod, + secrets: secrets, + store: store, + eventBus: eventBus, + logger: log, + searchCache: newTTLCache(), + prStatusCache: newTTLCache(), + prFeedbackCache: newPRFeedbackCache(), + mergeMethodsCache: newMergeMethodsCache(), + accessibleReposCache: newAccessibleReposCache(), + repoErrorCache: newRepoErrorCache(), + protectionCache: newBranchProtectionCache(), + rateTracker: NewRateTracker(eventBus, log), + tokenClientFactory: func(token string) Client { return NewPATClient(token) }, + ghAccountLister: ListGHAccounts, + cleanupFailureCounts: make(map[string]int), + appRegistrationRuntimes: make(map[string]*githubAppRuntime), + stopCtx: stopCtx, + stopCancel: stopCancel, } + if store != nil { + service.resolver = NewCredentialResolver(store, secrets) + service.resolver.SetLegacyFactory(func(ctx context.Context) (Client, string, error) { + return NewClient(ctx, secrets, log) + }) + } + return service +} + +// ListGHAccounts returns the configured account source. Production uses the +// host CLI; mock mode injects deterministic accounts and never shells out. +func (s *Service) ListGHAccounts(ctx context.Context) ([]GHAccount, error) { + if s == nil || s.ghAccountLister == nil { + return nil, ErrGitHubNotConfigured + } + return s.ghAccountLister(ctx) } // Stop cancels in-flight background goroutines (currently the @@ -229,6 +284,26 @@ func (s *Service) SetTaskSessionChecker(c TaskSessionChecker) { s.taskSessionChe // SetSecretManager sets the secret manager for token configuration operations. func (s *Service) SetSecretManager(m SecretManager) { s.secretManager = m } +// SetConnectionSecretStore wires deterministic workspace/user secret storage. +func (s *Service) SetConnectionSecretStore(store ConnectionSecretStore) { + s.connectionSecrets = store + if s.store != nil && store != nil { + s.personalConnections = NewStorePersonalConnectionRepository(s.store, store) + } + if s.resolver != nil { + s.resolver.secrets = store + s.resolver.SetInstallationProvider(&runtimeInstallationCredentialProvider{service: s}) + s.resolver.SetUserProvider(&runtimeUserCredentialProvider{service: s}) + } +} + +func (s *Service) InvalidateAppInstallationCredentials(registrationID string, installationID int64) { + runtime := s.currentAppRegistrationRuntime(registrationID) + if runtime != nil && runtime.installationTokens != nil { + runtime.installationTokens.Invalidate(installationID) + } +} + // Client returns the underlying GitHub client (may be nil if not authenticated). func (s *Service) Client() Client { return s.client diff --git a/apps/backend/internal/github/service_accessible_repos.go b/apps/backend/internal/github/service_accessible_repos.go index f3d66518c6..dae9c9937b 100644 --- a/apps/backend/internal/github/service_accessible_repos.go +++ b/apps/backend/internal/github/service_accessible_repos.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sort" + "strings" "time" ) @@ -58,8 +59,76 @@ func (s *Service) ListAccessibleRepos(ctx context.Context, query string, limit i if s.client == nil { return nil, ErrNoClient } + return s.listAccessibleReposWithClient(ctx, s.client, "legacy", query, limit) +} + +// ListAccessibleReposForWorkspace returns the personal identity's repositories +// intersected with both the workspace boundary and automation visibility. +func (s *Service) ListAccessibleReposForWorkspace( + ctx context.Context, workspaceID, userID, query string, limit int, +) ([]GitHubRepo, error) { + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, "", "") + if err != nil { + return nil, err + } + settings, err := s.GetWorkspaceSettings(ctx, workspaceID) + if err != nil { + return nil, err + } + repos, err := s.listAccessibleReposWithClient(ctx, resolved.Client, resolved.CacheScope, query, maxAccessibleReposLimit) + if err != nil { + return nil, err + } + automationRepos, err := s.listAutomationAccessibleRepos(ctx, workspaceID, query) + if err != nil { + return nil, err + } + automationVisible := repositorySet(automationRepos) + filtered := make([]GitHubRepo, 0, len(repos)) + for _, repo := range repos { + if repositoryInWorkspaceScope(settings, repo.Owner, repo.Name) && + repositorySetContains(automationVisible, repo.Owner, repo.Name) { + filtered = append(filtered, repo) + } + } limit = clampAccessibleReposLimit(limit) - key := accessibleReposCacheKey(query, limit) + if len(filtered) > limit { + filtered = filtered[:limit] + } + return filtered, nil +} + +func (s *Service) listAutomationAccessibleRepos( + ctx context.Context, + workspaceID, query string, +) ([]GitHubRepo, error) { + resolved, err := s.resolveAutomationClient(ctx, workspaceID, "", "") + if err != nil { + return nil, err + } + return s.listAccessibleReposWithClient( + ctx, resolved.Client, resolved.CacheScope, query, maxAccessibleReposLimit, + ) +} + +func repositorySet(repos []GitHubRepo) map[string]struct{} { + set := make(map[string]struct{}, len(repos)) + for _, repo := range repos { + set[strings.ToLower(strings.TrimSpace(repo.Owner)+"/"+strings.TrimSpace(repo.Name))] = struct{}{} + } + return set +} + +func repositorySetContains(set map[string]struct{}, owner, repo string) bool { + _, ok := set[strings.ToLower(strings.TrimSpace(owner)+"/"+strings.TrimSpace(repo))] + return ok +} + +func (s *Service) listAccessibleReposWithClient( + ctx context.Context, client Client, cacheScope, query string, limit int, +) ([]GitHubRepo, error) { + limit = clampAccessibleReposLimit(limit) + key := scopedCacheKey(cacheScope, accessibleReposCacheKey(query, limit)) if v, ok := s.accessibleReposCache.get(key); ok { return v.([]GitHubRepo), nil } @@ -73,7 +142,7 @@ func (s *Service) ListAccessibleRepos(ctx context.Context, query string, limit i // cancel (and SIGKILL) the shared fetch; bound it with our own timeout. fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), accessibleReposFetchTimeout) defer cancel() - repos, err := s.client.ListAccessibleRepos(fetchCtx, query, limit) + repos, err := client.ListAccessibleRepos(fetchCtx, query, limit) if err != nil { return nil, err } diff --git a/apps/backend/internal/github/service_app_auth.go b/apps/backend/internal/github/service_app_auth.go new file mode 100644 index 0000000000..963bd096f4 --- /dev/null +++ b/apps/backend/internal/github/service_app_auth.go @@ -0,0 +1,633 @@ +package github + +import ( + "context" + "errors" + "fmt" + "strings" +) + +type githubAppRuntime struct { + registrationID string + source DeploymentAppSource + generation int64 + appID int64 + appClient *AppClient + installationTokens *InstallationTokenCache + installationProvider *CachedInstallationCredentialProvider + appInstallationAuth *AppInstallationService + personalAuth *PersonalAuthService + webhookAuth *GitHubWebhookService +} + +type DeploymentAppRuntimeSnapshot struct { + RegistrationID string + Source DeploymentAppSource + Ready bool + AppID int64 + Generation int64 +} + +func (s *Service) ValidateAppRegistrationRuntime(resolved ResolvedDeploymentAppConfig) error { + _, err := s.buildDeploymentAppRuntime(resolved) + return err +} + +// ApplyAppRegistrationRuntime builds a complete App runtime before publishing +// it under its registration ID. A failure leaves every existing registration +// generation untouched. +func (s *Service) ApplyAppRegistrationRuntime(resolved ResolvedDeploymentAppConfig) error { + if s == nil { + return errors.New("GitHub service is not configured") + } + if resolved.Registration == nil || strings.TrimSpace(resolved.Registration.ID) == "" { + return errors.New("GitHub App registration ID is required") + } + runtime, err := s.buildDeploymentAppRuntime(resolved) + if err != nil { + return err + } + s.mu.Lock() + if s.appRegistrationRuntimes == nil { + s.appRegistrationRuntimes = make(map[string]*githubAppRuntime) + } + s.appRegistrationRuntimes[runtime.registrationID] = runtime + s.mu.Unlock() + if s.resolver != nil { + s.resolver.InvalidateAppRegistration(runtime.registrationID) + } + return nil +} + +// InvalidateAppRegistrationRuntime removes only the expected generation. A +// delayed invalidation cannot tear down a newer key rotation. +func (s *Service) InvalidateAppRegistrationRuntime(registrationID string, generation int64) { + if s == nil || strings.TrimSpace(registrationID) == "" { + return + } + s.mu.Lock() + runtime := s.appRegistrationRuntimes[registrationID] + if runtime != nil && (generation <= 0 || runtime.generation == generation) { + delete(s.appRegistrationRuntimes, registrationID) + } + s.mu.Unlock() + if runtime != nil && (generation <= 0 || runtime.generation == generation) && s.resolver != nil { + s.resolver.InvalidateAppRegistration(registrationID) + } +} + +func (s *Service) AppRegistrationRuntimeSnapshot(registrationID string) DeploymentAppRuntimeSnapshot { + runtime := s.currentAppRegistrationRuntime(registrationID) + if runtime == nil { + return DeploymentAppRuntimeSnapshot{RegistrationID: registrationID, Source: DeploymentAppSourceNone} + } + return DeploymentAppRuntimeSnapshot{ + RegistrationID: registrationID, Source: runtime.source, Ready: true, + AppID: runtime.appID, Generation: runtime.generation, + } +} + +func (s *Service) currentAppRegistrationRuntime(registrationID string) *githubAppRuntime { + if s == nil || strings.TrimSpace(registrationID) == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.appRegistrationRuntimes[registrationID] +} + +// InitializeAppRegistrationRuntimes independently loads every catalog entry. +// Invalid entries are reported together after all valid entries are active. +func (s *Service) InitializeAppRegistrationRuntimes(ctx context.Context) error { + if s == nil || s.store == nil || s.connectionSecrets == nil { + return errors.New("GitHub App registration dependencies are not configured") + } + repository := NewAppRegistrationRepository(s.store, s.connectionSecrets) + registrations, err := s.store.ListAppRegistrations(ctx) + if err != nil { + return err + } + var loadErrors []error + for _, registration := range registrations { + if registration == nil { + continue + } + resolved, resolveErr := ResolveAppRegistrationConfig(ctx, registration.ID, repository) + if resolveErr == nil { + resolveErr = s.ApplyAppRegistrationRuntime(resolved) + } + if resolveErr != nil { + loadErrors = append(loadErrors, fmt.Errorf("load GitHub App registration %s: %w", registration.ID, resolveErr)) + } + } + if cleanupErr := repository.CleanupOrphanedCredentialBundles(ctx); cleanupErr != nil { + loadErrors = append(loadErrors, cleanupErr) + } + return errors.Join(loadErrors...) +} + +func (s *Service) buildDeploymentAppRuntime( + resolved ResolvedDeploymentAppConfig, +) (*githubAppRuntime, error) { + if s.store == nil || s.connectionSecrets == nil { + return nil, errors.New("GitHub App authentication dependencies are not configured") + } + if resolved.Source != DeploymentAppSourceManaged { + return nil, errors.New("GitHub App runtime source is invalid") + } + if resolved.Registration == nil || strings.TrimSpace(resolved.Registration.ID) == "" { + return nil, errors.New("GitHub App registration ID is required") + } + if resolved.Registration.Status != AppRegistrationStatusActive { + return nil, errors.New("GitHub App registration is not active") + } + if err := resolved.Config.Validate(); err != nil { + return nil, fmt.Errorf("invalid GitHub App runtime configuration: %w", err) + } + privateKey, err := resolved.Config.PrivateKeyPEM() + if err != nil { + return nil, fmt.Errorf("load GitHub App private key: %w", err) + } + appClient, err := NewAppClient(resolved.Config.AppID, privateKey) + if err != nil { + return nil, fmt.Errorf("initialize GitHub App client: %w", err) + } + return s.buildDeploymentAppRuntimeWithClient(resolved, appClient) +} + +func (s *Service) buildDeploymentAppRuntimeWithClient( + resolved ResolvedDeploymentAppConfig, + appClient *AppClient, +) (*githubAppRuntime, error) { + config := resolved.Config + baseURL := strings.TrimRight(config.PublicBaseURL, "/") + if appClient == nil || baseURL == "" || config.ClientID == "" || config.ClientSecret == "" || + config.WebhookSecret == "" || config.Slug == "" { + return nil, errors.New("GitHub App runtime configuration is incomplete") + } + flows := NewOAuthFlowManager(s.store) + oauth := NewGitHubOAuthClient(config.ClientID, config.ClientSecret) + personalRepo := s.personalConnections + if personalRepo == nil { + personalRepo = NewStorePersonalConnectionRepository(s.store, s.connectionSecrets) + } + connectionStore := &serviceAppConnectionStore{service: s} + personal := NewPersonalAuthService(PersonalAuthConfig{ + RegistrationID: resolved.Registration.ID, + ClientID: config.ClientID, + CallbackURL: baseURL + "/api/v1/github/app/registrations/" + resolved.Registration.ID + "/personal/callback", + }, flows, personalRepo, oauth) + personal.SetWorkspaceMutationLock(s.workspaceConnectionMutationLock) + registrationID := resolved.Registration.ID + generation := resolved.Registration.CredentialGeneration + tokens := NewAppInstallationTokenCache(registrationID, generation, appClient) + return &githubAppRuntime{ + registrationID: registrationID, source: resolved.Source, generation: generation, appID: config.AppID, + appClient: appClient, installationTokens: tokens, + installationProvider: NewCachedInstallationCredentialProvider(tokens), + appInstallationAuth: NewAppInstallationService(AppInstallationConfig{ + RegistrationID: registrationID, Slug: config.Slug, + CallbackURL: baseURL + "/api/v1/github/app/registrations/" + registrationID + "/install/callback", + }, flows, connectionStore, appClient, oauth), + personalAuth: personal, + webhookAuth: NewAppRegistrationWebhookService( + registrationID, config.WebhookSecret, connectionStore, + &installationRepositorySettingsUpdater{service: s}, personalRepo, + GitHubWebhookReconciliation{Installations: appClient, Personal: personal}, + ), + }, nil +} + +func (s *Service) InitializeAppRegistrationLifecycle() error { + if s == nil || s.store == nil || s.connectionSecrets == nil { + return errors.New("GitHub App registration dependencies are not configured") + } + repository := NewAppRegistrationRepository(s.store, s.connectionSecrets) + s.mu.Lock() + s.appRegistrationLifecycle = NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ + Repository: repository, Store: s.store, Runtime: s, + Converter: NewManifestConversionClient(), + }) + s.mu.Unlock() + return nil +} + +func (s *Service) currentAppRegistrationLifecycle() *AppRegistrationLifecycleService { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.appRegistrationLifecycle +} + +func (s *Service) ListAppRegistrationCatalog( + ctx context.Context, + userID, workspaceID string, +) (AppRegistrationCatalog, error) { + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return AppRegistrationCatalog{}, err + } + lifecycle := s.currentAppRegistrationLifecycle() + if lifecycle == nil { + return AppRegistrationCatalog{}, ErrGitHubNotConfigured + } + return lifecycle.List(ctx, userID, workspaceID) +} + +func (s *Service) StartAppRegistrationManifest( + ctx context.Context, + userID string, + request AppRegistrationManifestStartRequest, +) (AppRegistrationManifestStart, error) { + if err := s.authorizeWorkspaceAccess(ctx, request.WorkspaceID); err != nil { + return AppRegistrationManifestStart{}, err + } + lifecycle := s.currentAppRegistrationLifecycle() + if lifecycle == nil { + return AppRegistrationManifestStart{}, ErrGitHubNotConfigured + } + return lifecycle.StartManifest(ctx, userID, request) +} + +func (s *Service) CompleteAppRegistrationManifest( + ctx context.Context, + registrationID string, + callback AppRegistrationManifestCallback, +) (AppRegistrationManifestResult, error) { + lifecycle := s.currentAppRegistrationLifecycle() + if lifecycle == nil { + return AppRegistrationManifestResult{}, ErrGitHubNotConfigured + } + return lifecycle.CompleteManifest(ctx, registrationID, callback) +} + +func (s *Service) ImportAppRegistration( + ctx context.Context, + userID string, + request AppRegistrationImportRequest, +) (*AppRegistration, error) { + if err := s.authorizeWorkspaceAccess(ctx, request.WorkspaceID); err != nil { + return nil, err + } + lifecycle := s.currentAppRegistrationLifecycle() + if lifecycle == nil { + return nil, ErrGitHubNotConfigured + } + return lifecycle.Import(ctx, userID, request) +} + +func (s *Service) PrepareAppRegistrationImport( + ctx context.Context, + userID string, + request AppRegistrationImportPrepareRequest, +) (AppRegistrationImportPreparationResult, error) { + if err := s.authorizeWorkspaceAccess(ctx, request.WorkspaceID); err != nil { + return AppRegistrationImportPreparationResult{}, err + } + lifecycle := s.currentAppRegistrationLifecycle() + if lifecycle == nil { + return AppRegistrationImportPreparationResult{}, ErrGitHubNotConfigured + } + return lifecycle.PrepareImport(ctx, userID, request) +} + +func (s *Service) RenameAppRegistration( + ctx context.Context, + userID, registrationID, displayName string, +) (*AppRegistration, error) { + lifecycle := s.currentAppRegistrationLifecycle() + if lifecycle == nil { + return nil, ErrGitHubNotConfigured + } + return lifecycle.Rename(ctx, userID, registrationID, displayName) +} + +func (s *Service) DeleteAppRegistration( + ctx context.Context, + userID, registrationID string, +) error { + lifecycle := s.currentAppRegistrationLifecycle() + if lifecycle == nil { + return ErrGitHubNotConfigured + } + return lifecycle.Delete(ctx, userID, registrationID) +} + +func (s *Service) ResetAppRegistrationsForE2E(ctx context.Context, workspaceID string) error { + lifecycle := s.currentAppRegistrationLifecycle() + if lifecycle == nil { + return nil + } + return lifecycle.ResetForE2E(ctx, workspaceID) +} + +type runtimeInstallationCredentialProvider struct{ service *Service } + +func (p *runtimeInstallationCredentialProvider) ResolveInstallation( + ctx context.Context, + connection *WorkspaceConnection, + req ResolveCredentialRequest, +) (*ResolvedCredential, error) { + if connection == nil || strings.TrimSpace(connection.AppRegistrationID) == "" { + return nil, ErrGitHubNotConfigured + } + runtime := p.service.currentAppRegistrationRuntime(connection.AppRegistrationID) + if runtime == nil || runtime.installationProvider == nil { + return nil, ErrGitHubNotConfigured + } + return runtime.installationProvider.ResolveInstallation(ctx, connection, req) +} + +func (p *runtimeInstallationCredentialProvider) AppCredentialGeneration( + registrationID string, +) (int64, bool) { + runtime := p.service.currentAppRegistrationRuntime(registrationID) + if runtime == nil { + return 0, false + } + return runtime.generation, true +} + +type runtimeUserCredentialProvider struct{ service *Service } + +func (p *runtimeUserCredentialProvider) ResolveUser( + ctx context.Context, + connection *UserConnection, + req ResolveCredentialRequest, +) (*ResolvedCredential, error) { + if connection == nil || strings.TrimSpace(connection.AppRegistrationID) == "" { + return nil, ErrGitHubPersonalRequired + } + runtime := p.service.currentAppRegistrationRuntime(connection.AppRegistrationID) + if runtime == nil || runtime.personalAuth == nil { + return nil, ErrGitHubPersonalRequired + } + return (&personalAuthCredentialProvider{service: runtime.personalAuth}).ResolveUser(ctx, connection, req) +} + +func (s *Service) SetCredentialBroker(broker *CredentialBroker) { + s.mu.Lock() + defer s.mu.Unlock() + s.credentialBroker = broker +} + +func (s *Service) CredentialBrokerReady() bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return s.credentialBroker != nil +} + +func (s *Service) ConfigureCredentialBroker(authorizer BrokerScopeAuthorizer) error { + if s == nil || s.store == nil || s.resolver == nil || authorizer == nil { + return errors.New("GitHub credential broker dependencies are not configured") + } + s.SetCredentialBroker(NewCredentialBroker(s.store, s.resolver, authorizer)) + return nil +} + +func (s *Service) IssueGitHubCredentialLease( + ctx context.Context, + request CredentialLeaseRequest, +) (*CredentialLease, error) { + s.mu.Lock() + broker := s.credentialBroker + s.mu.Unlock() + if broker == nil { + return nil, ErrGitHubNotConfigured + } + return broker.Issue(ctx, request) +} + +func (s *Service) ResolveGitHubCredential( + ctx context.Context, + request BrokerCredentialRequest, +) (*BrokerCredential, error) { + s.mu.Lock() + broker := s.credentialBroker + s.mu.Unlock() + if broker == nil { + return nil, ErrGitHubNotConfigured + } + return broker.Resolve(ctx, request) +} + +type WorkspaceAutomationStatus struct { + *WorkspaceConnection + Actor *AuthPrincipal `json:"actor,omitempty"` + Capabilities map[GitHubAppCapability]bool `json:"capabilities,omitempty"` + MissingCapabilities []GitHubAppCapability `json:"missing_capabilities,omitempty"` + RateLimit *GitHubRateLimitInfo `json:"rate_limit,omitempty"` + LegacyMigration bool `json:"legacy_migration"` +} + +type WorkspaceAuthStatus struct { + WorkspaceID string `json:"workspace_id"` + Automation *WorkspaceAutomationStatus `json:"automation,omitempty"` + Personal *UserConnection `json:"personal,omitempty"` + EffectivePersonalActor *AuthPrincipal `json:"effective_personal_actor,omitempty"` + EffectiveManualMutationActor *AuthPrincipal `json:"effective_manual_mutation_actor,omitempty"` + AppRegistration *AppRegistration `json:"app_registration,omitempty"` + GitHubAppAvailable bool `json:"github_app_available"` + AppAvailable bool `json:"app_available"` + Authenticated bool `json:"authenticated"` + Username string `json:"username"` + AuthMethod string `json:"auth_method"` + TokenConfigured bool `json:"token_configured"` + RequiredScopes []string `json:"required_scopes"` + RateLimit *GitHubRateLimitInfo `json:"rate_limit,omitempty"` +} + +func (s *Service) GetWorkspaceAuthStatus( + ctx context.Context, + workspaceID, userID string, +) (*WorkspaceAuthStatus, error) { + if strings.TrimSpace(workspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } + if s == nil || s.store == nil { + return nil, ErrGitHubNotConfigured + } + connection, err := s.store.GetWorkspaceConnection(ctx, workspaceID) + if err != nil { + return nil, err + } + personal, err := s.workspacePersonalConnection(ctx, workspaceID, userID) + if err != nil { + return nil, err + } + status := &WorkspaceAuthStatus{ + WorkspaceID: workspaceID, Personal: personal, RequiredScopes: RequiredGitHubScopes, + AuthMethod: AuthMethodNone, + } + if connection == nil { + return status, nil + } + status.AuthMethod = string(connection.Source) + status.TokenConfigured = connection.Source == ConnectionSourcePAT + status.Automation = &WorkspaceAutomationStatus{ + WorkspaceConnection: connection, + LegacyMigration: connection.Source == ConnectionSourceLegacyShared, + } + if err := s.populateAppRegistrationStatus(ctx, connection, status); err != nil { + return nil, err + } + if connection.Status != ConnectionStatusActive || s.resolver == nil { + return status, nil + } + automation, resolveErr := s.resolver.Resolve(ctx, ResolveCredentialRequest{ + WorkspaceID: workspaceID, Purpose: CredentialPurposeAutomation, + }) + if resolveErr != nil || automation == nil { + return status, nil + } + status.Authenticated = true + status.Username = automation.Principal.Login + status.Automation.Actor = principalPointer(automation.Principal) + status.Automation.Capabilities = automation.Capabilities + status.Automation.MissingCapabilities = missingGitHubCapabilities(automation.Capabilities) + status.Automation.RateLimit = rateLimitInfoForTracker(automation.RateTracker) + status.RateLimit = status.Automation.RateLimit + + s.populateEffectivePersonalActors(ctx, workspaceID, userID, status) + return status, nil +} + +func (s *Service) workspacePersonalConnection( + ctx context.Context, + workspaceID, userID string, +) (*UserConnection, error) { + if userID == "" { + return nil, nil + } + return s.store.GetUserConnection(ctx, workspaceID, userID) +} + +func (s *Service) populateAppRegistrationStatus( + ctx context.Context, + connection *WorkspaceConnection, + status *WorkspaceAuthStatus, +) error { + if connection.Source != ConnectionSourceGitHubAppInstallation || connection.AppRegistrationID == "" { + return nil + } + registration, err := s.store.GetAppRegistration(ctx, connection.AppRegistrationID) + if err != nil || registration == nil { + return err + } + status.AppRegistration = cloneDeploymentAppRegistration(registration) + runtime := s.AppRegistrationRuntimeSnapshot(registration.ID) + status.GitHubAppAvailable = registration.Status == AppRegistrationStatusActive && runtime.Ready + status.AppAvailable = status.GitHubAppAvailable + return nil +} + +func (s *Service) populateEffectivePersonalActors( + ctx context.Context, + workspaceID, userID string, + status *WorkspaceAuthStatus, +) { + personalActor, _ := s.resolver.Resolve(ctx, ResolveCredentialRequest{ + WorkspaceID: workspaceID, UserID: userID, Purpose: CredentialPurposePersonalRead, + }) + if personalActor != nil { + status.EffectivePersonalActor = principalPointer(personalActor.Principal) + } + mutationActor, _ := s.resolver.Resolve(ctx, ResolveCredentialRequest{ + WorkspaceID: workspaceID, UserID: userID, Purpose: CredentialPurposePersonalWrite, + }) + if mutationActor != nil { + status.EffectiveManualMutationActor = principalPointer(mutationActor.Principal) + } +} + +func principalPointer(principal AuthPrincipal) *AuthPrincipal { + copy := principal + return © +} + +func missingGitHubCapabilities(capabilities map[GitHubAppCapability]bool) []GitHubAppCapability { + missing := make([]GitHubAppCapability, 0) + for _, capability := range allGitHubCapabilities { + if !capabilities[capability] { + missing = append(missing, capability) + } + } + return missing +} + +func (s *Service) StartAppInstallation( + ctx context.Context, + workspaceID, userID, registrationID string, +) (AppInstallationStart, error) { + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return AppInstallationStart{}, err + } + runtime := s.currentAppRegistrationRuntime(registrationID) + if runtime == nil || runtime.appInstallationAuth == nil { + return AppInstallationStart{}, ErrGitHubNotConfigured + } + return runtime.appInstallationAuth.Start(ctx, workspaceID, userID) +} + +func (s *Service) CompleteAppInstallation( + ctx context.Context, + registrationID string, + callback AppInstallationCallback, +) (AppInstallationResult, error) { + runtime := s.currentAppRegistrationRuntime(registrationID) + if runtime == nil || runtime.appInstallationAuth == nil { + return AppInstallationResult{}, ErrGitHubNotConfigured + } + return runtime.appInstallationAuth.Complete(ctx, callback) +} + +func (s *Service) StartPersonalAuth( + ctx context.Context, + workspaceID, userID string, +) (PersonalAuthStart, error) { + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return PersonalAuthStart{}, err + } + connection, err := s.store.GetWorkspaceConnection(ctx, workspaceID) + if err != nil { + return PersonalAuthStart{}, err + } + if connection == nil || connection.Source != ConnectionSourceGitHubAppInstallation || + strings.TrimSpace(connection.AppRegistrationID) == "" { + return PersonalAuthStart{}, ErrGitHubPersonalRequired + } + runtime := s.currentAppRegistrationRuntime(connection.AppRegistrationID) + if runtime == nil || runtime.personalAuth == nil { + return PersonalAuthStart{}, ErrGitHubNotConfigured + } + return runtime.personalAuth.Start(ctx, workspaceID, userID) +} + +func (s *Service) CompletePersonalAuth( + ctx context.Context, + registrationID string, + callback PersonalAuthCallback, +) (PersonalAuthResult, error) { + runtime := s.currentAppRegistrationRuntime(registrationID) + if runtime == nil || runtime.personalAuth == nil { + return PersonalAuthResult{}, ErrGitHubNotConfigured + } + return runtime.personalAuth.Complete(ctx, callback) +} + +func (s *Service) DisconnectPersonalAuth(ctx context.Context, workspaceID, userID string) error { + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return err + } + if s == nil || s.personalConnections == nil { + return nil + } + return s.personalConnections.RevokePersonalConnection(ctx, workspaceID, userID) +} diff --git a/apps/backend/internal/github/service_app_auth_connection_store.go b/apps/backend/internal/github/service_app_auth_connection_store.go new file mode 100644 index 0000000000..e69b8906ed --- /dev/null +++ b/apps/backend/internal/github/service_app_auth_connection_store.go @@ -0,0 +1,171 @@ +package github + +import ( + "context" + "errors" + "fmt" + "strings" + "time" +) + +// serviceAppConnectionStore adapts the workspace connection store for App +// installation, webhook, and personal-auth flows. +type serviceAppConnectionStore struct { + service *Service +} + +func (r *serviceAppConnectionStore) GetWorkspaceConnection(ctx context.Context, workspaceID string) (*WorkspaceConnection, error) { + return r.service.store.GetWorkspaceConnection(ctx, workspaceID) +} + +func (r *serviceAppConnectionStore) UpsertWorkspaceConnection(ctx context.Context, connection *WorkspaceConnection) error { + lock := r.service.workspaceConnectionMutationLock(connection.WorkspaceID) + lock.Lock() + defer lock.Unlock() + existing, err := r.service.store.GetWorkspaceConnection(ctx, connection.WorkspaceID) + if err != nil { + return err + } + connection.CredentialGeneration = nextCredentialGeneration(existing) + return r.upsertWorkspaceConnectionLocked(ctx, existing, connection) +} + +func (r *serviceAppConnectionStore) ReplaceWorkspaceConnection(ctx context.Context, connection *WorkspaceConnection, expected WorkspaceConnectionExpectation) error { + lock := r.service.workspaceConnectionMutationLock(connection.WorkspaceID) + lock.Lock() + defer lock.Unlock() + existing, err := r.service.store.GetWorkspaceConnection(ctx, connection.WorkspaceID) + if err != nil { + return err + } + if !matchesWorkspaceConnectionExpectation(existing, expected) || connection.CredentialGeneration != expected.CredentialGeneration+1 { + return ErrOAuthFlowStale + } + return r.upsertWorkspaceConnectionLocked(ctx, existing, connection) +} + +func (r *serviceAppConnectionStore) upsertWorkspaceConnectionLocked(ctx context.Context, existing, connection *WorkspaceConnection) error { + var err error + if existing != nil && !existing.CreatedAt.IsZero() { + connection.CreatedAt = existing.CreatedAt + } + var previousPAT string + var hadPreviousPAT bool + if existing != nil && existing.Source == ConnectionSourcePAT { + previousPAT, hadPreviousPAT, err = revealOptionalSecret(ctx, r.service.connectionSecrets, WorkspacePATSecretKey(connection.WorkspaceID)) + if err != nil { + return err + } + } + if err := r.service.applyAutomationTransition(ctx, existing, connection, func() error { return r.service.store.UpsertWorkspaceConnection(ctx, connection) }); err != nil { + return fmt.Errorf("replace workspace GitHub connection: %w", err) + } + if existing != nil && existing.Source == ConnectionSourcePAT && hadPreviousPAT { + if err := r.service.connectionSecrets.Delete(ctx, WorkspacePATSecretKey(connection.WorkspaceID)); err != nil { + return errors.Join(err, restoreWorkspaceConnection(ctx, r.service.store, existing, connection.WorkspaceID), restoreOptionalSecret(ctx, r.service.connectionSecrets, WorkspacePATSecretKey(connection.WorkspaceID), workspacePATSecretName, previousPAT, hadPreviousPAT)) + } + } + if existing != nil && existing.InstallationID != nil { + r.service.InvalidateAppInstallationCredentials(existing.AppRegistrationID, *existing.InstallationID) + } + r.service.invalidateWorkspaceCredential(connection.WorkspaceID) + return nil +} + +func restoreOptionalSecret(ctx context.Context, secrets ConnectionSecretStore, key, name, value string, existed bool) error { + if existed { + return secrets.Set(ctx, key, name, value) + } + return deleteOptionalSecret(ctx, secrets, key) +} + +func (r *serviceAppConnectionStore) ListWorkspaceConnectionsByInstallation(ctx context.Context, installationID int64) ([]*WorkspaceConnection, error) { + return r.service.store.ListWorkspaceConnectionsByInstallation(ctx, installationID) +} +func (r *serviceAppConnectionStore) ListWorkspaceConnectionsByAppInstallation(ctx context.Context, registrationID string, installationID int64) ([]*WorkspaceConnection, error) { + return r.service.store.ListWorkspaceConnectionsByAppInstallation(ctx, registrationID, installationID) +} + +func (r *serviceAppConnectionStore) TransitionWorkspaceInstallationConnection(ctx context.Context, expected, next *WorkspaceConnection) (bool, error) { + lock := r.service.workspaceConnectionMutationLock(expected.WorkspaceID) + lock.Lock() + defer lock.Unlock() + updated, err := r.service.store.TransitionWorkspaceInstallationConnection(ctx, expected, next) + if err != nil || !updated { + return updated, err + } + if expected.InstallationID != nil { + r.service.InvalidateAppInstallationCredentials(expected.AppRegistrationID, *expected.InstallationID) + } + r.service.invalidateWorkspaceCredential(expected.WorkspaceID) + return true, nil +} + +func (r *serviceAppConnectionStore) ListUserConnectionsByGitHubUser(ctx context.Context, githubUserID int64) ([]*UserConnection, error) { + return r.service.store.ListUserConnectionsByGitHubUser(ctx, githubUserID) +} +func (r *serviceAppConnectionStore) ListUserConnectionsByAppGitHubUser(ctx context.Context, registrationID string, githubUserID int64) ([]*UserConnection, error) { + return r.service.store.ListUserConnectionsByAppGitHubUser(ctx, registrationID, githubUserID) +} +func (r *serviceAppConnectionStore) ClaimWebhookDelivery(ctx context.Context, delivery *WebhookDelivery, staleBefore time.Time) (WebhookDeliveryClaim, error) { + return r.service.store.ClaimWebhookDelivery(ctx, delivery, staleBefore) +} +func (r *serviceAppConnectionStore) CompleteWebhookDelivery(ctx context.Context, deliveryID string, status WebhookDeliveryStatus, result string, processedAt time.Time) error { + return r.service.store.CompleteWebhookDelivery(ctx, deliveryID, status, result, processedAt) +} +func (r *serviceAppConnectionStore) CompleteAppRegistrationWebhookDelivery(ctx context.Context, registrationID, deliveryID string, status WebhookDeliveryStatus, result string, processedAt time.Time) error { + return r.service.store.CompleteAppRegistrationWebhookDelivery(ctx, registrationID, deliveryID, status, result, processedAt) +} + +func restoreWorkspaceConnection(ctx context.Context, store *Store, connection *WorkspaceConnection, workspaceID string) error { + if connection == nil { + return store.DeleteWorkspaceConnection(ctx, workspaceID) + } + return store.UpsertWorkspaceConnection(ctx, connection) +} + +type installationRepositorySettingsUpdater struct{ service *Service } + +func (u *installationRepositorySettingsUpdater) ApplyInstallationRepositories(ctx context.Context, change InstallationRepositoriesChange) (bool, error) { + if u == nil || u.service == nil { + return false, errors.New("GitHub installation repository updater is not configured") + } + lock := u.service.workspaceConnectionMutationLock(change.WorkspaceID) + lock.Lock() + defer lock.Unlock() + connection, err := u.service.store.GetWorkspaceConnection(ctx, change.WorkspaceID) + if err != nil { + return false, err + } + expectedInstallationID := change.InstallationID + if !matchesWorkspaceConnectionExpectation(connection, WorkspaceConnectionExpectation{Source: change.ConnectionSource, CredentialGeneration: change.CredentialGeneration, InstallationID: &expectedInstallationID, AppRegistrationID: change.AppRegistrationID}) || connection.Status != ConnectionStatusActive { + return false, nil + } + u.service.InvalidateAppInstallationCredentials(change.AppRegistrationID, change.InstallationID) + u.service.invalidateWorkspaceCredential(change.WorkspaceID) + if len(change.Removed) == 0 { + return true, nil + } + settings, err := u.service.store.GetWorkspaceSettings(ctx, change.WorkspaceID) + if err != nil || settings == nil || settings.RepoScopeMode != RepoScopeModeRepos { + return err == nil, err + } + removed := make(map[string]struct{}, len(change.Removed)) + for _, repository := range change.Removed { + removed[strings.ToLower(repository.FullName)] = struct{}{} + } + filtered := settings.RepoScopeRepos[:0] + for _, repository := range settings.RepoScopeRepos { + if _, ok := removed[strings.ToLower(repository.Owner+"/"+repository.Name)]; !ok { + filtered = append(filtered, repository) + } + } + if len(filtered) == len(settings.RepoScopeRepos) { + return true, nil + } + settings.RepoScopeRepos = filtered + if err := u.service.store.UpsertWorkspaceSettings(ctx, settings); err != nil { + return false, err + } + return true, nil +} diff --git a/apps/backend/internal/github/service_app_auth_test.go b/apps/backend/internal/github/service_app_auth_test.go new file mode 100644 index 0000000000..4621eabd90 --- /dev/null +++ b/apps/backend/internal/github/service_app_auth_test.go @@ -0,0 +1,248 @@ +package github + +import ( + "context" + "errors" + "testing" +) + +func TestAppAuthRegistryHotAddsAndInvalidatesRegistrationsIndependently(t *testing.T) { + store := newTestStore(t) + service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + service.SetConnectionSecretStore(newFakeConnectionSecrets()) + + first := testResolvedAppRegistration(t, "registration-a", 101, 1) + second := testResolvedAppRegistration(t, "registration-b", 202, 3) + if err := service.ApplyAppRegistrationRuntime(first); err != nil { + t.Fatalf("ApplyAppRegistrationRuntime(first): %v", err) + } + if err := service.ApplyAppRegistrationRuntime(second); err != nil { + t.Fatalf("ApplyAppRegistrationRuntime(second): %v", err) + } + + if got := service.AppRegistrationRuntimeSnapshot("registration-a"); !got.Ready || got.AppID != 101 || got.Generation != 1 { + t.Fatalf("first snapshot = %+v", got) + } + if got := service.AppRegistrationRuntimeSnapshot("registration-b"); !got.Ready || got.AppID != 202 || got.Generation != 3 { + t.Fatalf("second snapshot = %+v", got) + } + + service.InvalidateAppRegistrationRuntime("registration-a", 1) + if got := service.AppRegistrationRuntimeSnapshot("registration-a"); got.Ready { + t.Fatalf("invalidated snapshot = %+v", got) + } + if got := service.AppRegistrationRuntimeSnapshot("registration-b"); !got.Ready { + t.Fatalf("unrelated snapshot = %+v", got) + } +} + +func TestAppAuthRegistryRejectsOneRegistrationWithoutReplacingAnother(t *testing.T) { + store := newTestStore(t) + service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + service.SetConnectionSecretStore(newFakeConnectionSecrets()) + valid := testResolvedAppRegistration(t, "registration-a", 101, 1) + if err := service.ApplyAppRegistrationRuntime(valid); err != nil { + t.Fatal(err) + } + invalid := testResolvedAppRegistration(t, "registration-b", 202, 1) + invalid.Config.PrivateKey = "not a private key" + if err := service.ApplyAppRegistrationRuntime(invalid); err == nil { + t.Fatal("invalid registration unexpectedly loaded") + } + if got := service.AppRegistrationRuntimeSnapshot("registration-a"); !got.Ready { + t.Fatalf("valid registration was removed: %+v", got) + } + if got := service.AppRegistrationRuntimeSnapshot("registration-b"); got.Ready { + t.Fatalf("invalid registration became ready: %+v", got) + } +} + +func TestAppAuthRegistryStaleInvalidationDoesNotRemoveNewGeneration(t *testing.T) { + store := newTestStore(t) + service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + service.SetConnectionSecretStore(newFakeConnectionSecrets()) + if err := service.ApplyAppRegistrationRuntime(testResolvedAppRegistration(t, "registration-a", 101, 2)); err != nil { + t.Fatal(err) + } + service.InvalidateAppRegistrationRuntime("registration-a", 1) + if got := service.AppRegistrationRuntimeSnapshot("registration-a"); !got.Ready || got.Generation != 2 { + t.Fatalf("new generation was removed by stale invalidation: %+v", got) + } +} + +func testResolvedAppRegistration( + t *testing.T, + registrationID string, + appID, generation int64, +) ResolvedDeploymentAppConfig { + t.Helper() + _, privateKey := testAppPrivateKey(t) + return ResolvedDeploymentAppConfig{ + Source: DeploymentAppSourceManaged, + Registration: &AppRegistration{ + ID: registrationID, Source: AppRegistrationSourceManaged, DisplayName: registrationID, + GitHubHost: "github.com", AppID: appID, ClientID: "Iv1.client", Slug: "kandev-app", + OwnerLogin: "acme", OwnerType: AppRegistrationOwnerOrganization, + Visibility: AppRegistrationVisibilityPrivate, + PublicBaseURL: "https://kandev.example", CredentialGeneration: generation, + CredentialSecretID: "github:app-registration:" + registrationID + ":test", + Status: AppRegistrationStatusActive, WebhookStatus: DeploymentAppWebhookUnverified, + }, + Config: AppRegistrationRuntimeConfig{ + AppID: appID, ClientID: "Iv1.client", ClientSecret: "client-secret", + PrivateKey: string(privateKey), WebhookSecret: "webhook-secret", + Slug: "kandev-app", PublicBaseURL: "https://kandev.example", + }, + } +} + +func TestAppAuthRegistryLoadsValidRegistrationsWhenAnotherFails(t *testing.T) { + store := newTestStore(t) + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + valid := testResolvedAppRegistration(t, "registration-a", 101, 1) + if err := repository.SaveRegistration(context.Background(), valid.Registration, DeploymentAppCredentials{ + PrivateKey: valid.Config.PrivateKey, ClientSecret: valid.Config.ClientSecret, + WebhookSecret: valid.Config.WebhookSecret, + }); err != nil { + t.Fatal(err) + } + invalid := *valid.Registration + invalid.ID = "registration-b" + invalid.AppID = 202 + invalid.CredentialGeneration = 1 + if err := repository.SaveRegistration(context.Background(), &invalid, DeploymentAppCredentials{ + PrivateKey: "invalid", ClientSecret: "client-secret", WebhookSecret: "webhook-secret", + }); err != nil { + t.Fatal(err) + } + + service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + service.SetConnectionSecretStore(secrets) + err := service.InitializeAppRegistrationRuntimes(context.Background()) + if err == nil { + t.Fatal("startup did not report invalid registration") + } + if got := service.AppRegistrationRuntimeSnapshot("registration-a"); !got.Ready { + t.Fatalf("valid runtime not loaded: %+v", got) + } + if got := service.AppRegistrationRuntimeSnapshot("registration-b"); got.Ready { + t.Fatalf("invalid runtime loaded: %+v", got) + } +} + +func TestAppAuthRegistrySkipsPersistedInvalidRegistration(t *testing.T) { + store := newTestStore(t) + secrets := newFakeConnectionSecrets() + repository := NewAppRegistrationRepository(store, secrets) + active := testResolvedAppRegistration(t, "registration-active", 101, 1) + invalid := testResolvedAppRegistration(t, "registration-invalid", 202, 1) + invalid.Registration.Status = AppRegistrationStatusInvalid + for _, resolved := range []ResolvedDeploymentAppConfig{active, invalid} { + if err := repository.SaveRegistration(context.Background(), resolved.Registration, DeploymentAppCredentials{ + PrivateKey: resolved.Config.PrivateKey, ClientSecret: resolved.Config.ClientSecret, + WebhookSecret: resolved.Config.WebhookSecret, + }); err != nil { + t.Fatal(err) + } + } + + service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + service.SetConnectionSecretStore(secrets) + if err := service.InitializeAppRegistrationRuntimes(context.Background()); err == nil { + t.Fatal("startup did not report invalid registration") + } + if got := service.AppRegistrationRuntimeSnapshot(active.Registration.ID); !got.Ready { + t.Fatalf("active registration was not loaded: %+v", got) + } + if got := service.AppRegistrationRuntimeSnapshot(invalid.Registration.ID); got.Ready { + t.Fatalf("invalid registration was loaded: %+v", got) + } + if err := service.ApplyAppRegistrationRuntime(invalid); err == nil { + t.Fatalf("ApplyAppRegistrationRuntime(invalid) error = %v", err) + } +} + +func TestWorkspaceAuthStatusUsesSelectedAppRegistration(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-a", "ws-b", "ws-invalid") + service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + service.SetConnectionSecretStore(newFakeConnectionSecrets()) + activeA := testResolvedAppRegistration(t, "registration-a", 101, 1) + activeB := testResolvedAppRegistration(t, "registration-b", 202, 1) + invalid := testResolvedAppRegistration(t, "registration-invalid", 303, 1) + invalid.Registration.Status = AppRegistrationStatusInvalid + for _, resolved := range []ResolvedDeploymentAppConfig{activeA, activeB} { + if err := service.ApplyAppRegistrationRuntime(resolved); err != nil { + t.Fatal(err) + } + } + for _, registration := range []*AppRegistration{ + activeA.Registration, activeB.Registration, invalid.Registration, + } { + if err := store.UpsertDeploymentAppRegistration(context.Background(), registration); err != nil { + t.Fatal(err) + } + } + installationID := int64(42) + for workspaceID, registrationID := range map[string]string{ + "ws-a": "registration-a", "ws-b": "registration-b", "ws-invalid": "registration-invalid", + } { + if err := store.UpsertWorkspaceConnection(context.Background(), &WorkspaceConnection{ + WorkspaceID: workspaceID, Source: ConnectionSourceGitHubAppInstallation, + GitHubHost: defaultGitHubHost, InstallationID: &installationID, + InstallationAccountLogin: "acme", InstallationAccountType: "Organization", + AppRegistrationID: registrationID, Status: ConnectionStatusActive, + CredentialGeneration: 1, + }); err != nil { + t.Fatal(err) + } + } + + for workspaceID, registrationID := range map[string]string{"ws-a": "registration-a", "ws-b": "registration-b"} { + status, err := service.GetWorkspaceAuthStatus(context.Background(), workspaceID, DefaultUserID) + if err != nil { + t.Fatal(err) + } + if status.AppRegistration == nil || status.AppRegistration.ID != registrationID || + !status.GitHubAppAvailable || !status.AppAvailable { + t.Fatalf("status for %s = %+v", workspaceID, status) + } + } + status, err := service.GetWorkspaceAuthStatus(context.Background(), "ws-invalid", DefaultUserID) + if err != nil { + t.Fatal(err) + } + if status.AppRegistration == nil || status.AppRegistration.ID != "registration-invalid" || + status.GitHubAppAvailable || status.AppAvailable { + t.Fatalf("invalid registration status = %+v", status) + } +} + +func TestServicePreparesImportAndRejectsWrongBinding(t *testing.T) { + _, service, store := setupAppRegistrationController(t) + seedConnectionWorkspaces(t, store, "workspace-1", "workspace-2") + prepared, err := service.PrepareAppRegistrationImport( + context.Background(), DefaultUserID, AppRegistrationImportPrepareRequest{ + WorkspaceID: "workspace-1", PublicBaseURL: "https://kandev.example", + }, + ) + if err != nil { + t.Fatal(err) + } + if prepared.RegistrationID == "" || prepared.PublicBaseURL != "https://kandev.example" || + prepared.SetupURL != prepared.InstallCallbackURL { + t.Fatalf("prepared import = %+v", prepared) + } + _, err = service.ImportAppRegistration(context.Background(), DefaultUserID, AppRegistrationImportRequest{ + RegistrationID: prepared.RegistrationID, WorkspaceID: "workspace-2", + PublicBaseURL: prepared.PublicBaseURL, + }) + if !errors.Is(err, ErrAppRegistrationImportPreparationUnavailable) { + t.Fatalf("wrong workspace import error = %v", err) + } + stored, err := store.GetAppRegistrationImportPreparation(context.Background(), prepared.RegistrationID) + if err != nil || stored == nil || stored.ConsumedAt != nil { + t.Fatalf("wrong binding consumed preparation = %+v, err %v", stored, err) + } +} diff --git a/apps/backend/internal/github/service_auth_test.go b/apps/backend/internal/github/service_auth_test.go new file mode 100644 index 0000000000..72086f8105 --- /dev/null +++ b/apps/backend/internal/github/service_auth_test.go @@ -0,0 +1,99 @@ +package github + +import ( + "context" + "testing" +) + +const testWorkspaceID = "workspace-test" + +type testConnectionReader struct { + workspaces map[string]*WorkspaceConnection +} + +func (r testConnectionReader) GetWorkspaceConnection( + _ context.Context, + workspaceID string, +) (*WorkspaceConnection, error) { + return r.workspaces[workspaceID], nil +} + +func (testConnectionReader) GetUserConnection( + context.Context, + string, + string, +) (*UserConnection, error) { + return nil, nil +} + +type testAutomationCredentialProvider struct { + client Client +} + +func (p testAutomationCredentialProvider) ResolveAutomation( + _ context.Context, + connection *WorkspaceConnection, + _ ResolveCredentialRequest, +) (*ResolvedCredential, error) { + return &ResolvedCredential{ + Client: p.client, + Capabilities: allTokenCapabilities(), + Principal: AuthPrincipal{ + Kind: AuthPrincipalHuman, + Source: ConnectionSourcePAT, + Login: connection.Login, + }, + }, nil +} + +// configureTestWorkspaceAuth gives legacy service fixtures an explicit, +// workspace-owned PAT principal while preserving their injected mock client. +func configureTestWorkspaceAuth( + t *testing.T, + service *Service, + client Client, + workspaceIDs ...string, +) { + t.Helper() + connections := make(map[string]*WorkspaceConnection, len(workspaceIDs)) + for _, workspaceID := range workspaceIDs { + connections[workspaceID] = &WorkspaceConnection{ + WorkspaceID: workspaceID, + Source: ConnectionSourcePAT, + GitHubHost: defaultGitHubHost, + Login: "test-user", + Status: ConnectionStatusActive, + CredentialGeneration: 1, + } + } + service.resolver = NewCredentialResolver(testConnectionReader{workspaces: connections}, nil) + service.resolver.SetAutomationProvider(testAutomationCredentialProvider{client: client}) +} + +func newWorkspaceAuthenticatedTestService( + t *testing.T, + client Client, + store *Store, + workspaceIDs ...string, +) *Service { + t.Helper() + service := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + configureTestWorkspaceAuth(t, service, client, workspaceIDs...) + return service +} + +func withTestWorkspace(watch *PRWatch) *PRWatch { + if watch.WorkspaceID == "" { + watch.WorkspaceID = testWorkspaceID + } + return watch +} + +func testAutomationScope(t *testing.T, service *Service, workspaceID string) string { + t.Helper() + resolved, err := service.resolveAutomationClient(context.Background(), workspaceID, "", "") + if err != nil { + t.Fatalf("resolve test automation client: %v", err) + } + return resolved.CacheScope +} diff --git a/apps/backend/internal/github/service_automation_auth.go b/apps/backend/internal/github/service_automation_auth.go new file mode 100644 index 0000000000..3978b1d8ae --- /dev/null +++ b/apps/backend/internal/github/service_automation_auth.go @@ -0,0 +1,63 @@ +package github + +import "context" + +// ResolveGitHubAutomationClient returns the workspace-owned automation client +// for non-repository GitHub operations such as Gist-backed task sharing. +func (s *Service) ResolveGitHubAutomationClient(ctx context.Context, workspaceID string) (Client, error) { + resolved, err := s.resolveAutomationClient(ctx, workspaceID, "", "") + if err != nil { + return nil, err + } + return resolved.Client, nil +} + +func (s *Service) FindPRByBranchForWorkspace( + ctx context.Context, + workspaceID, owner, repo, branch string, +) (*PR, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + resolved, err := s.resolveAutomationClient(ctx, workspaceID, owner, repo) + if err != nil { + return nil, err + } + return resolved.Client.FindPRByBranch(ctx, owner, repo, branch) +} + +func (s *Service) GetPRFeedbackForAutomation( + ctx context.Context, + workspaceID, owner, repo string, + number int, +) (*PRFeedback, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + resolved, err := s.resolveAutomationClient(ctx, workspaceID, owner, repo) + if err != nil { + return nil, err + } + return s.getPRFeedback(ctx, resolved.Client, resolved.CacheScope, owner, repo, number) +} + +func (s *Service) MergePRForAutomation( + ctx context.Context, + workspaceID, owner, repo string, + number int, + mergeMethod string, +) error { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return err + } + resolved, err := s.resolveAutomationClient(ctx, workspaceID, owner, repo) + if err != nil { + return err + } + if err := requireGitHubCapability(resolved, CapabilityPullRequestWrite); err != nil { + return err + } + return s.mergePRWithClient( + ctx, resolved.Client, resolved.CacheScope, owner, repo, number, mergeMethod, + ) +} diff --git a/apps/backend/internal/github/service_cleanup.go b/apps/backend/internal/github/service_cleanup.go index b731769bb7..9ad580a92a 100644 --- a/apps/backend/internal/github/service_cleanup.go +++ b/apps/backend/internal/github/service_cleanup.go @@ -12,15 +12,22 @@ import ( // CleanupMergedReviewTasks checks PRs tracked by a review watch and deletes // tasks whose PRs are merged/closed. Returns the number of tasks deleted. func (s *Service) CleanupMergedReviewTasks(ctx context.Context, watch *ReviewWatch) (int, error) { - if s.client == nil || s.taskDeleter == nil { + if s.taskDeleter == nil { return 0, nil } + if watch == nil || watch.WorkspaceID == "" { + return 0, ErrGitHubWorkspaceRequired + } + resolved, err := s.resolveAutomationClient(ctx, watch.WorkspaceID, "", "") + if err != nil { + return 0, err + } prTasks, err := s.store.ListReviewPRTasksByWatch(ctx, watch.ID) if err != nil { return 0, fmt.Errorf("list review PR tasks: %w", err) } policy := NormalizeCleanupPolicy(watch.CleanupPolicy) - return s.cleanupReviewPRTaskBatch(ctx, prTasks, func(_ *ReviewPRTask) string { return policy }), nil + return s.cleanupReviewPRTaskBatch(ctx, resolved.Client, prTasks, func(_ *ReviewPRTask) string { return policy }), nil } // CleanupAllOrphanedReviewTasks sweeps dedup rows whose watch is deleted or @@ -46,9 +53,13 @@ func (s *Service) CleanupAllReviewTasks(ctx context.Context) (int, error) { // //nolint:dupl // mirrors CleanupIssueTasksForWorkspace — different task/watch types func (s *Service) CleanupReviewTasksForWorkspace(ctx context.Context, workspaceID string) (int, error) { - if s.client == nil || s.taskDeleter == nil { + if s.taskDeleter == nil { return 0, nil } + resolved, err := s.resolveAutomationClient(ctx, workspaceID, "", "") + if err != nil { + return 0, err + } watches, err := s.store.ListReviewWatches(ctx, workspaceID) if err != nil { return 0, fmt.Errorf("list review watches: %w", err) @@ -70,7 +81,7 @@ func (s *Service) CleanupReviewTasksForWorkspace(ctx context.Context, workspaceI candidates = append(candidates, rpt) } } - return s.cleanupReviewPRTaskBatch(ctx, candidates, func(rpt *ReviewPRTask) string { + return s.cleanupReviewPRTaskBatch(ctx, resolved.Client, candidates, func(rpt *ReviewPRTask) string { return watchPolicies[rpt.ReviewWatchID] }), nil } @@ -81,7 +92,7 @@ func (s *Service) CleanupReviewTasksForWorkspace(ctx context.Context, workspaceI // //nolint:dupl // mirrors cleanupAllIssueTasks — different types, same orchestration func (s *Service) cleanupAllReviewTasks(ctx context.Context, orphansOnly bool) (int, error) { - if s.client == nil || s.taskDeleter == nil { + if s.taskDeleter == nil { return 0, nil } prTasks, err := s.store.ListAllReviewPRTasks(ctx) @@ -91,12 +102,12 @@ func (s *Service) cleanupAllReviewTasks(ctx context.Context, orphansOnly bool) ( if len(prTasks) == 0 { return 0, nil } - policyCache, enabledCache, unknownCache := s.buildReviewWatchCaches(ctx, prTasks) + policyCache, enabledCache, unknownCache, workspaceCache := s.buildReviewWatchCaches(ctx, prTasks) // Allocate a fresh slice rather than reusing prTasks' backing array // via prTasks[:0]; the in-place version is safe today (write index // always trails read index) but silently breaks if a future caller // reads prTasks after the filter. - candidates := make([]*ReviewPRTask, 0, len(prTasks)) + candidatesByWorkspace := make(map[string][]*ReviewPRTask) for _, rpt := range prTasks { // Watch fetch failed — skip this cycle so we don't fail-open and // reap an enabled-watch row under the wrong policy. @@ -108,19 +119,25 @@ func (s *Service) cleanupAllReviewTasks(ctx context.Context, orphansOnly bool) ( if orphansOnly && enabledCache[rpt.ReviewWatchID] { continue } - candidates = append(candidates, rpt) - } - if len(candidates) == 0 { - return 0, nil - } - return s.cleanupReviewPRTaskBatch(ctx, candidates, func(rpt *ReviewPRTask) string { - if p, ok := policyCache[rpt.ReviewWatchID]; ok { - return p + workspaceID := workspaceCache[rpt.ReviewWatchID] + if workspaceID == "" { + continue } - // Watch was deleted: fall back to the documented default so the row - // (and any task it points at) can still be reaped. - return CleanupPolicyAuto - }), nil + candidatesByWorkspace[workspaceID] = append(candidatesByWorkspace[workspaceID], rpt) + } + deleted := 0 + for workspaceID, candidates := range candidatesByWorkspace { + resolved, resolveErr := s.resolveAutomationClient(ctx, workspaceID, "", "") + if resolveErr != nil { + s.logger.Warn("skip review cleanup for unavailable workspace connection", + zap.String("workspace_id", workspaceID), zap.Error(resolveErr)) + continue + } + deleted += s.cleanupReviewPRTaskBatch(ctx, resolved.Client, candidates, func(rpt *ReviewPRTask) string { + return policyCache[rpt.ReviewWatchID] + }) + } + return deleted, nil } // buildReviewWatchCaches loads the cleanup policy + enabled flag for each @@ -135,7 +152,9 @@ func (s *Service) cleanupAllReviewTasks(ctx context.Context, orphansOnly bool) ( // `unknown`, so callers treat them as legitimate orphans. // //nolint:dupl // mirrors buildIssueWatchCaches — different task/watch types -func (s *Service) buildReviewWatchCaches(ctx context.Context, prTasks []*ReviewPRTask) (policy map[string]string, enabled map[string]bool, unknown map[string]bool) { +func (s *Service) buildReviewWatchCaches( + ctx context.Context, prTasks []*ReviewPRTask, +) (policy map[string]string, enabled map[string]bool, unknown map[string]bool, workspace map[string]string) { seen := make(map[string]struct{}) for _, rpt := range prTasks { seen[rpt.ReviewWatchID] = struct{}{} @@ -143,6 +162,7 @@ func (s *Service) buildReviewWatchCaches(ctx context.Context, prTasks []*ReviewP policy = make(map[string]string, len(seen)) enabled = make(map[string]bool, len(seen)) unknown = make(map[string]bool) + workspace = make(map[string]string, len(seen)) for watchID := range seen { watch, err := s.store.GetReviewWatch(ctx, watchID) if err != nil { @@ -156,8 +176,9 @@ func (s *Service) buildReviewWatchCaches(ctx context.Context, prTasks []*ReviewP } policy[watchID] = NormalizeCleanupPolicy(watch.CleanupPolicy) enabled[watchID] = watch.Enabled + workspace[watchID] = watch.WorkspaceID } - return policy, enabled, unknown + return policy, enabled, unknown, workspace } // deleteTaskWithReason deletes a task, threading the cleanup reason through to @@ -175,7 +196,9 @@ func (s *Service) deleteTaskWithReason(ctx context.Context, taskID, reason strin // supply it so per-watch and global-sweep paths can share this body. // //nolint:dupl // mirrors cleanupIssueTaskBatch — different types, same structure -func (s *Service) cleanupReviewPRTaskBatch(ctx context.Context, prTasks []*ReviewPRTask, resolvePolicy func(*ReviewPRTask) string) int { +func (s *Service) cleanupReviewPRTaskBatch( + ctx context.Context, client Client, prTasks []*ReviewPRTask, resolvePolicy func(*ReviewPRTask) string, +) int { deleted := 0 for _, rpt := range prTasks { policy := resolvePolicy(rpt) @@ -189,7 +212,7 @@ func (s *Service) cleanupReviewPRTaskBatch(ctx context.Context, prTasks []*Revie // increment `deleted`: the count is reported back to the // settings-page toast as "Deleted N tasks", and these rows // never had an associated task. - if should, _ := s.shouldDeleteReviewTask(ctx, rpt, policy); should { + if should, _ := s.shouldDeleteReviewTaskWithClient(ctx, client, rpt, policy); should { if err := s.store.DeleteReviewPRTask(ctx, rpt.ID); err != nil { s.logger.Warn("failed to delete orphan reservation row", zap.String("dedup_id", rpt.ID), zap.Error(err)) @@ -197,7 +220,7 @@ func (s *Service) cleanupReviewPRTaskBatch(ctx context.Context, prTasks []*Revie } continue } - shouldDelete, reason := s.shouldDeleteReviewTask(ctx, rpt, policy) + shouldDelete, reason := s.shouldDeleteReviewTaskWithClient(ctx, client, rpt, policy) if !shouldDelete { continue } @@ -245,11 +268,17 @@ func (s *Service) cleanupReviewPRTaskBatch(ctx context.Context, prTasks []*Revie // Terminal state covers: PR merged/closed, OR the authenticated user already // approved the PR on GitHub (so it's effectively done from their POV). func (s *Service) shouldDeleteReviewTask(ctx context.Context, rpt *ReviewPRTask, policy string) (bool, string) { + return s.shouldDeleteReviewTaskWithClient(ctx, s.client, rpt, policy) +} + +func (s *Service) shouldDeleteReviewTaskWithClient( + ctx context.Context, client Client, rpt *ReviewPRTask, policy string, +) (bool, string) { if policy == CleanupPolicyNever { return false, "" } failureKey := reviewFailureKey(rpt) - feedback, err := s.client.GetPRFeedback(ctx, rpt.RepoOwner, rpt.RepoName, rpt.PRNumber) + feedback, err := client.GetPRFeedback(ctx, rpt.RepoOwner, rpt.RepoName, rpt.PRNumber) if err != nil { s.trackCleanupFailure(failureKey, "review", rpt.RepoOwner+"/"+rpt.RepoName, rpt.PRNumber, err) return false, "" @@ -263,7 +292,7 @@ func (s *Service) shouldDeleteReviewTask(ctx context.Context, rpt *ReviewPRTask, reason = "pr_merged_or_closed" //nolint:goconst // also referenced by string in service_cleanup_policy_test.go; introducing a constant would require coupling the test } else { // Check if the authenticated user already approved the PR on GitHub. - user, _ := s.client.GetAuthenticatedUser(ctx) + user, _ := client.GetAuthenticatedUser(ctx) for _, review := range feedback.Reviews { if review.State == "APPROVED" && review.Author == user { reason = "pr_approved_by_user" @@ -338,15 +367,22 @@ func (s *Service) resetCleanupFailure(key string) { // //nolint:dupl // mirrors CleanupMergedReviewTasks — different types, same structure func (s *Service) CleanupClosedIssueTasks(ctx context.Context, watch *IssueWatch) (int, error) { - if s.client == nil || s.taskDeleter == nil { + if s.taskDeleter == nil { return 0, nil } + if watch == nil || watch.WorkspaceID == "" { + return 0, ErrGitHubWorkspaceRequired + } + resolved, err := s.resolveAutomationClient(ctx, watch.WorkspaceID, "", "") + if err != nil { + return 0, err + } issueTasks, err := s.store.ListIssueWatchTasksByWatch(ctx, watch.ID) if err != nil { return 0, fmt.Errorf("list issue watch tasks: %w", err) } policy := NormalizeCleanupPolicy(watch.CleanupPolicy) - return s.cleanupIssueTaskBatch(ctx, issueTasks, func(_ *IssueWatchTask) string { return policy }), nil + return s.cleanupIssueTaskBatch(ctx, resolved.Client, issueTasks, func(_ *IssueWatchTask) string { return policy }), nil } // CleanupAllOrphanedIssueTasks sweeps dedup rows whose watch is deleted or @@ -366,9 +402,13 @@ func (s *Service) CleanupAllIssueTasks(ctx context.Context) (int, error) { // //nolint:dupl // mirrors CleanupReviewTasksForWorkspace — different task/watch types func (s *Service) CleanupIssueTasksForWorkspace(ctx context.Context, workspaceID string) (int, error) { - if s.client == nil || s.taskDeleter == nil { + if s.taskDeleter == nil { return 0, nil } + resolved, err := s.resolveAutomationClient(ctx, workspaceID, "", "") + if err != nil { + return 0, err + } watches, err := s.store.ListIssueWatches(ctx, workspaceID) if err != nil { return 0, fmt.Errorf("list issue watches: %w", err) @@ -390,14 +430,14 @@ func (s *Service) CleanupIssueTasksForWorkspace(ctx context.Context, workspaceID candidates = append(candidates, it) } } - return s.cleanupIssueTaskBatch(ctx, candidates, func(it *IssueWatchTask) string { + return s.cleanupIssueTaskBatch(ctx, resolved.Client, candidates, func(it *IssueWatchTask) string { return watchPolicies[it.IssueWatchID] }), nil } //nolint:dupl // mirrors cleanupAllReviewTasks — different types, same orchestration func (s *Service) cleanupAllIssueTasks(ctx context.Context, orphansOnly bool) (int, error) { - if s.client == nil || s.taskDeleter == nil { + if s.taskDeleter == nil { return 0, nil } issueTasks, err := s.store.ListAllIssueWatchTasks(ctx) @@ -407,8 +447,8 @@ func (s *Service) cleanupAllIssueTasks(ctx context.Context, orphansOnly bool) (i if len(issueTasks) == 0 { return 0, nil } - policyCache, enabledCache, unknownCache := s.buildIssueWatchCaches(ctx, issueTasks) - candidates := make([]*IssueWatchTask, 0, len(issueTasks)) + policyCache, enabledCache, unknownCache, workspaceCache := s.buildIssueWatchCaches(ctx, issueTasks) + candidatesByWorkspace := make(map[string][]*IssueWatchTask) for _, it := range issueTasks { if unknownCache[it.IssueWatchID] { continue @@ -416,21 +456,31 @@ func (s *Service) cleanupAllIssueTasks(ctx context.Context, orphansOnly bool) (i if orphansOnly && enabledCache[it.IssueWatchID] { continue } - candidates = append(candidates, it) - } - if len(candidates) == 0 { - return 0, nil - } - return s.cleanupIssueTaskBatch(ctx, candidates, func(it *IssueWatchTask) string { - if p, ok := policyCache[it.IssueWatchID]; ok { - return p + workspaceID := workspaceCache[it.IssueWatchID] + if workspaceID == "" { + continue } - return CleanupPolicyAuto - }), nil + candidatesByWorkspace[workspaceID] = append(candidatesByWorkspace[workspaceID], it) + } + deleted := 0 + for workspaceID, candidates := range candidatesByWorkspace { + resolved, resolveErr := s.resolveAutomationClient(ctx, workspaceID, "", "") + if resolveErr != nil { + s.logger.Warn("skip issue cleanup for unavailable workspace connection", + zap.String("workspace_id", workspaceID), zap.Error(resolveErr)) + continue + } + deleted += s.cleanupIssueTaskBatch(ctx, resolved.Client, candidates, func(it *IssueWatchTask) string { + return policyCache[it.IssueWatchID] + }) + } + return deleted, nil } //nolint:dupl // mirrors buildReviewWatchCaches — different task/watch types -func (s *Service) buildIssueWatchCaches(ctx context.Context, issueTasks []*IssueWatchTask) (policy map[string]string, enabled map[string]bool, unknown map[string]bool) { +func (s *Service) buildIssueWatchCaches( + ctx context.Context, issueTasks []*IssueWatchTask, +) (policy map[string]string, enabled map[string]bool, unknown map[string]bool, workspace map[string]string) { seen := make(map[string]struct{}) for _, it := range issueTasks { seen[it.IssueWatchID] = struct{}{} @@ -438,6 +488,7 @@ func (s *Service) buildIssueWatchCaches(ctx context.Context, issueTasks []*Issue policy = make(map[string]string, len(seen)) enabled = make(map[string]bool, len(seen)) unknown = make(map[string]bool) + workspace = make(map[string]string, len(seen)) for watchID := range seen { watch, err := s.store.GetIssueWatch(ctx, watchID) if err != nil { @@ -451,19 +502,22 @@ func (s *Service) buildIssueWatchCaches(ctx context.Context, issueTasks []*Issue } policy[watchID] = NormalizeCleanupPolicy(watch.CleanupPolicy) enabled[watchID] = watch.Enabled + workspace[watchID] = watch.WorkspaceID } - return policy, enabled, unknown + return policy, enabled, unknown, workspace } //nolint:dupl // mirrors cleanupReviewPRTaskBatch — different types, same structure -func (s *Service) cleanupIssueTaskBatch(ctx context.Context, issueTasks []*IssueWatchTask, resolvePolicy func(*IssueWatchTask) string) int { +func (s *Service) cleanupIssueTaskBatch( + ctx context.Context, client Client, issueTasks []*IssueWatchTask, resolvePolicy func(*IssueWatchTask) string, +) int { deleted := 0 for _, it := range issueTasks { policy := resolvePolicy(it) if it.TaskID == "" { // Orphan reservation row — no task was created. Clean it up // but don't count it as a deleted task. - if should, _ := s.shouldDeleteIssueTask(ctx, it, policy); should { + if should, _ := s.shouldDeleteIssueTaskWithClient(ctx, client, it, policy); should { if err := s.store.DeleteIssueWatchTask(ctx, it.ID); err != nil { s.logger.Warn("failed to delete orphan reservation row", zap.String("dedup_id", it.ID), zap.Error(err)) @@ -471,7 +525,7 @@ func (s *Service) cleanupIssueTaskBatch(ctx context.Context, issueTasks []*Issue } continue } - shouldDelete, reason := s.shouldDeleteIssueTask(ctx, it, policy) + shouldDelete, reason := s.shouldDeleteIssueTaskWithClient(ctx, client, it, policy) if !shouldDelete { continue } @@ -509,11 +563,17 @@ func (s *Service) cleanupIssueTaskBatch(ctx context.Context, issueTasks []*Issue // shouldDeleteIssueTask checks whether an issue task is eligible for cleanup. // Policy gating mirrors shouldDeleteReviewTask. func (s *Service) shouldDeleteIssueTask(ctx context.Context, it *IssueWatchTask, policy string) (bool, string) { + return s.shouldDeleteIssueTaskWithClient(ctx, s.client, it, policy) +} + +func (s *Service) shouldDeleteIssueTaskWithClient( + ctx context.Context, client Client, it *IssueWatchTask, policy string, +) (bool, string) { if policy == CleanupPolicyNever { return false, "" } failureKey := issueFailureKey(it) - state, err := s.client.GetIssueState(ctx, it.RepoOwner, it.RepoName, it.IssueNumber) + state, err := client.GetIssueState(ctx, it.RepoOwner, it.RepoName, it.IssueNumber) if err != nil { s.trackCleanupFailure(failureKey, "issue", it.RepoOwner+"/"+it.RepoName, it.IssueNumber, err) return false, "" diff --git a/apps/backend/internal/github/service_cleanup_policy_test.go b/apps/backend/internal/github/service_cleanup_policy_test.go index 5442aacc73..34af724dbd 100644 --- a/apps/backend/internal/github/service_cleanup_policy_test.go +++ b/apps/backend/internal/github/service_cleanup_policy_test.go @@ -169,7 +169,7 @@ func TestShouldDeleteReviewTask_GitHubErrorThenSuccess_ResetsCounter(t *testing. } } -func TestCleanupAllOrphanedReviewTasks_DeletedWatchOrphans(t *testing.T) { +func TestCleanupAllOrphanedReviewTasks_DeletedWatchWithoutWorkspaceFailsClosed(t *testing.T) { _, svc, mockClient, store := setupPollerTest(t) ctx := context.Background() @@ -204,11 +204,11 @@ func TestCleanupAllOrphanedReviewTasks_DeletedWatchOrphans(t *testing.T) { if err != nil { t.Fatalf("CleanupAllOrphanedReviewTasks: %v", err) } - if deleted != 1 { - t.Fatalf("deleted=%d, want 1 (orphan with missing watch should fall back to auto policy and reap)", deleted) + if deleted != 0 { + t.Fatalf("deleted=%d, want 0 when workspace ownership cannot be resolved", deleted) } - if len(rec.calls) != 1 || rec.calls[0] != "task-77" { - t.Fatalf("DeleteTask calls=%v, want [task-77]", rec.calls) + if len(rec.calls) != 0 { + t.Fatalf("DeleteTask calls=%v, want none without workspace authentication", rec.calls) } } @@ -420,7 +420,9 @@ func TestCheckReviewWatches_RunsOrphanSweepWhenNoEnabledWatches(t *testing.T) { func TestCheckIssueWatches_RunsOrphanSweepWhenNoEnabledWatches(t *testing.T) { poller, svc, _, store := setupPollerTest(t) ctx := context.Background() - svc.client = newIssueStateMockClient(map[int]string{9002: "closed"}) + client := newIssueStateMockClient(map[int]string{9002: "closed"}) + svc.client = client + configureTestWorkspaceAuth(t, svc, client, "ws-1") watch := &IssueWatch{WorkspaceID: "ws-1", Enabled: false} if err := store.CreateIssueWatch(ctx, watch); err != nil { @@ -467,10 +469,12 @@ func (c *issueStateMockClient) GetIssueState(_ context.Context, _, _ string, num return "open", nil } -func TestCleanupAllOrphanedIssueTasks_DeletedWatchOrphans(t *testing.T) { +func TestCleanupAllOrphanedIssueTasks_DeletedWatchWithoutWorkspaceFailsClosed(t *testing.T) { _, svc, _, store := setupPollerTest(t) ctx := context.Background() - svc.client = newIssueStateMockClient(map[int]string{77: "closed"}) + client := newIssueStateMockClient(map[int]string{77: "closed"}) + svc.client = client + configureTestWorkspaceAuth(t, svc, client, "ws-1") watch := &IssueWatch{WorkspaceID: "ws-1", Enabled: true} if err := store.CreateIssueWatch(ctx, watch); err != nil { @@ -495,18 +499,20 @@ func TestCleanupAllOrphanedIssueTasks_DeletedWatchOrphans(t *testing.T) { if err != nil { t.Fatalf("CleanupAllOrphanedIssueTasks: %v", err) } - if deleted != 1 { - t.Fatalf("deleted=%d, want 1 (orphan with missing watch should fall back to auto policy and reap)", deleted) + if deleted != 0 { + t.Fatalf("deleted=%d, want 0 when workspace ownership cannot be resolved", deleted) } - if len(rec.calls) != 1 || rec.calls[0] != "task-77" { - t.Fatalf("DeleteTask calls=%v, want [task-77]", rec.calls) + if len(rec.calls) != 0 { + t.Fatalf("DeleteTask calls=%v, want none without workspace authentication", rec.calls) } } func TestCleanupAllOrphanedIssueTasks_DisabledWatch_StillCleansUp(t *testing.T) { _, svc, _, store := setupPollerTest(t) ctx := context.Background() - svc.client = newIssueStateMockClient(map[int]string{88: "closed"}) + client := newIssueStateMockClient(map[int]string{88: "closed"}) + svc.client = client + configureTestWorkspaceAuth(t, svc, client, "ws-1") watch := &IssueWatch{WorkspaceID: "ws-1", Enabled: false} if err := store.CreateIssueWatch(ctx, watch); err != nil { @@ -532,7 +538,9 @@ func TestCleanupAllOrphanedIssueTasks_DisabledWatch_StillCleansUp(t *testing.T) func TestCleanupAllOrphanedIssueTasks_UnknownWatchSkipped(t *testing.T) { _, svc, _, store := setupPollerTest(t) ctx := context.Background() - svc.client = newIssueStateMockClient(map[int]string{222: "closed"}) + client := newIssueStateMockClient(map[int]string{222: "closed"}) + svc.client = client + configureTestWorkspaceAuth(t, svc, client, "ws-1") watch := &IssueWatch{WorkspaceID: "ws-1", Enabled: true, CleanupPolicy: CleanupPolicyNever} if err := store.CreateIssueWatch(ctx, watch); err != nil { @@ -568,7 +576,9 @@ func TestCleanupAllOrphanedIssueTasks_UnknownWatchSkipped(t *testing.T) { func TestCleanupAllOrphanedIssueTasks_SkipsEnabledWatchRows(t *testing.T) { _, svc, _, store := setupPollerTest(t) ctx := context.Background() - svc.client = newIssueStateMockClient(map[int]string{111: "closed"}) + client := newIssueStateMockClient(map[int]string{111: "closed"}) + svc.client = client + configureTestWorkspaceAuth(t, svc, client, "ws-1") watch := &IssueWatch{WorkspaceID: "ws-1", Enabled: true} if err := store.CreateIssueWatch(ctx, watch); err != nil { @@ -606,7 +616,9 @@ func TestCleanupAllOrphanedIssueTasks_SkipsEnabledWatchRows(t *testing.T) { func TestCleanupAllOrphanedIssueTasks_RespectsNeverPolicy(t *testing.T) { _, svc, _, store := setupPollerTest(t) ctx := context.Background() - svc.client = newIssueStateMockClient(map[int]string{55: "closed"}) + client := newIssueStateMockClient(map[int]string{55: "closed"}) + svc.client = client + configureTestWorkspaceAuth(t, svc, client, "ws-1") watch := &IssueWatch{WorkspaceID: "ws-1", Enabled: true, CleanupPolicy: CleanupPolicyNever} if err := store.CreateIssueWatch(ctx, watch); err != nil { diff --git a/apps/backend/internal/github/service_connections.go b/apps/backend/internal/github/service_connections.go new file mode 100644 index 0000000000..cfbb178542 --- /dev/null +++ b/apps/backend/internal/github/service_connections.go @@ -0,0 +1,406 @@ +package github + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/kandev/kandev/internal/auth/authn" +) + +const workspacePATSecretName = "GitHub workspace PAT" + +// ErrWorkspaceConnectionStale prevents a validated request from replacing a +// newer workspace connection that committed while validation was in flight. +var ErrWorkspaceConnectionStale = errors.New("GitHub workspace connection changed") + +// ErrGHCLIOperatorRequired prevents authenticated members from binding a +// deployment host gh CLI account to a workspace. +var ErrGHCLIOperatorRequired = errors.New("GitHub CLI operator access is required") + +type SetWorkspaceConnectionRequest struct { + Source ConnectionSource `json:"source"` + Token string `json:"token,omitempty"` + Host string `json:"host,omitempty"` + Login string `json:"login,omitempty"` +} + +// GetWorkspaceConnectionHealth exposes aggregate persisted connection state +// for system health without falling back to startup or ambient credentials. +func (s *Service) GetWorkspaceConnectionHealth(ctx context.Context) (WorkspaceConnectionHealth, error) { + if s == nil || s.store == nil { + return WorkspaceConnectionHealth{}, ErrGitHubNotConfigured + } + return s.store.GetWorkspaceConnectionHealth(ctx) +} + +func (s *Service) SetWorkspaceConnection( + ctx context.Context, + workspaceID string, + req SetWorkspaceConnectionRequest, +) (*WorkspaceConnection, error) { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return nil, ErrGitHubWorkspaceRequired + } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } + if s.store == nil || s.connectionSecrets == nil { + return nil, ErrGitHubNotConfigured + } + observed, err := s.store.GetWorkspaceConnection(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("load workspace connection: %w", err) + } + expectedGeneration := workspaceConnectionGeneration(observed) + + switch req.Source { + case ConnectionSourcePAT: + token := strings.TrimSpace(req.Token) + if token == "" { + return nil, fmt.Errorf("GitHub token is required") + } + login, err := s.validateWorkspaceToken(ctx, token) + if err != nil { + return nil, err + } + return s.commitWorkspacePAT(ctx, workspaceID, login, token, expectedGeneration) + case ConnectionSourceGHCLI: + if err := requireGHCLIOperator(ctx); err != nil { + return nil, err + } + return s.validateAndCommitWorkspaceGHCLI( + ctx, workspaceID, req.Host, req.Login, expectedGeneration, + ) + case ConnectionSourceLegacyShared: + return nil, fmt.Errorf("legacy shared GitHub auth is migration-only") + case ConnectionSourceGitHubAppInstallation: + return nil, fmt.Errorf("GitHub App installation must use the verified installation flow") + default: + return nil, fmt.Errorf("unsupported GitHub connection source %q", req.Source) + } +} + +func requireGHCLIOperator(ctx context.Context) error { + identity, ok := authn.IdentityFromContext(ctx) + if !ok { + // Identity-free callers are internal lifecycle operations. HTTP requests + // always carry either a real identity or the auth-disabled synthetic admin. + return nil + } + if identity.IsAdmin() { + return nil + } + return ErrGHCLIOperatorRequired +} + +func (s *Service) newWorkspaceConnection( + workspaceID string, + source ConnectionSource, + existing *WorkspaceConnection, +) *WorkspaceConnection { + now := time.Now().UTC() + connection := &WorkspaceConnection{ + WorkspaceID: workspaceID, Source: source, GitHubHost: defaultGitHubHost, + Status: ConnectionStatusActive, CredentialGeneration: nextCredentialGeneration(existing), + CreatedAt: now, UpdatedAt: now, + } + if existing != nil && !existing.CreatedAt.IsZero() { + connection.CreatedAt = existing.CreatedAt + } + return connection +} + +func nextCredentialGeneration(existing *WorkspaceConnection) int64 { + if existing == nil || existing.CredentialGeneration < 1 { + return 1 + } + return existing.CredentialGeneration + 1 +} + +func workspaceConnectionGeneration(connection *WorkspaceConnection) int64 { + if connection == nil { + return 0 + } + return connection.CredentialGeneration +} + +func (s *Service) commitWorkspacePAT( + ctx context.Context, + workspaceID, login, token string, + expectedGeneration int64, +) (*WorkspaceConnection, error) { + lock := s.workspaceConnectionMutationLock(workspaceID) + lock.Lock() + defer lock.Unlock() + existing, err := s.store.GetWorkspaceConnection(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("load workspace connection: %w", err) + } + if workspaceConnectionGeneration(existing) != expectedGeneration { + return nil, ErrWorkspaceConnectionStale + } + connection := s.newWorkspaceConnection(workspaceID, ConnectionSourcePAT, existing) + connection.Login = login + secretKey := WorkspacePATSecretKey(connection.WorkspaceID) + previous, hadPrevious, err := revealOptionalSecret(ctx, s.connectionSecrets, secretKey) + if err != nil { + return nil, fmt.Errorf("load previous workspace PAT: %w", err) + } + if err := s.connectionSecrets.Set(ctx, secretKey, workspacePATSecretName, token); err != nil { + return nil, fmt.Errorf("store workspace PAT: %w", err) + } + if err := s.applyAutomationTransition(ctx, existing, connection, func() error { + return s.store.UpsertWorkspaceConnection(ctx, connection) + }); err != nil { + s.compensatePATWrite(ctx, secretKey, previous, hadPrevious) + return nil, fmt.Errorf("replace workspace GitHub connection: %w", err) + } + if existing != nil && existing.InstallationID != nil { + s.InvalidateAppInstallationCredentials(existing.AppRegistrationID, *existing.InstallationID) + } + s.invalidateWorkspaceCredential(connection.WorkspaceID) + return connection, nil +} + +func (s *Service) validateAndCommitWorkspaceGHCLI( + ctx context.Context, + workspaceID, host, login string, + expectedGeneration int64, +) (*WorkspaceConnection, error) { + host = strings.TrimSpace(host) + if host == "" { + host = defaultGitHubHost + } + login = strings.TrimSpace(login) + if login == "" { + return nil, fmt.Errorf("GitHub login is required") + } + tokenResolver := ResolveGHAccountToken + if s.resolver != nil && s.resolver.ghToken != nil { + tokenResolver = s.resolver.ghToken + } + token, err := tokenResolver(ctx, host, login) + if err != nil { + return nil, err + } + validatedLogin, err := s.validateWorkspaceToken(ctx, token) + if err != nil { + return nil, err + } + if !strings.EqualFold(login, validatedLogin) { + return nil, fmt.Errorf("selected gh login %q resolved as %q", login, validatedLogin) + } + return s.commitWorkspaceGHCLI(ctx, workspaceID, host, validatedLogin, expectedGeneration) +} + +func (s *Service) commitWorkspaceGHCLI( + ctx context.Context, + workspaceID, host, login string, + expectedGeneration int64, +) (*WorkspaceConnection, error) { + lock := s.workspaceConnectionMutationLock(workspaceID) + lock.Lock() + defer lock.Unlock() + existing, err := s.store.GetWorkspaceConnection(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("load workspace connection: %w", err) + } + if workspaceConnectionGeneration(existing) != expectedGeneration { + return nil, ErrWorkspaceConnectionStale + } + connection := s.newWorkspaceConnection(workspaceID, ConnectionSourceGHCLI, existing) + connection.GitHubHost = host + connection.Login = login + if err := s.applyAutomationTransition(ctx, existing, connection, func() error { + return s.store.UpsertWorkspaceConnection(ctx, connection) + }); err != nil { + return nil, fmt.Errorf("replace workspace GitHub connection: %w", err) + } + if existing != nil && existing.Source == ConnectionSourcePAT { + if err := deleteOptionalSecret(ctx, s.connectionSecrets, WorkspacePATSecretKey(workspaceID)); err != nil { + return nil, errors.Join(err, restoreWorkspaceConnection(ctx, s.store, existing, workspaceID)) + } + } + if existing != nil && existing.InstallationID != nil { + s.InvalidateAppInstallationCredentials(existing.AppRegistrationID, *existing.InstallationID) + } + s.invalidateWorkspaceCredential(connection.WorkspaceID) + return connection, nil +} + +func (s *Service) revokePersonalForAutomationTransition( + ctx context.Context, + existing, replacement *WorkspaceConnection, +) error { + if existing == nil || existing.Source != ConnectionSourceGitHubAppInstallation { + return nil + } + if replacement != nil && replacement.Source == ConnectionSourceGitHubAppInstallation && + existing.AppRegistrationID == replacement.AppRegistrationID && + equalInstallationID(existing.InstallationID, replacement.InstallationID) { + return nil + } + if s.personalConnections == nil { + return errors.New("personal GitHub connection repository is not configured") + } + return s.personalConnections.RevokeWorkspacePersonalConnections(ctx, existing.WorkspaceID) +} + +func (s *Service) applyAutomationTransition( + ctx context.Context, + existing, replacement *WorkspaceConnection, + mutation func() error, +) error { + if existing == nil || existing.Source != ConnectionSourceGitHubAppInstallation || + (replacement != nil && replacement.Source == ConnectionSourceGitHubAppInstallation && + existing.AppRegistrationID == replacement.AppRegistrationID && + equalInstallationID(existing.InstallationID, replacement.InstallationID)) { + return mutation() + } + if s.personalConnections == nil { + return errors.New("personal GitHub connection repository is not configured") + } + return s.personalConnections.TransitionWorkspacePersonalConnections(ctx, existing.WorkspaceID, mutation) +} + +func equalInstallationID(left, right *int64) bool { + return left != nil && right != nil && *left == *right +} + +func (s *Service) validateWorkspaceToken(ctx context.Context, token string) (string, error) { + factory := s.tokenClientFactory + if factory == nil { + factory = func(value string) Client { return NewPATClient(value) } + } + login, err := factory(token).GetAuthenticatedUser(ctx) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidToken, err) + } + login = strings.TrimSpace(login) + if login == "" { + return "", fmt.Errorf("%w: GitHub returned an empty login", ErrInvalidToken) + } + return login, nil +} + +func revealOptionalSecret(ctx context.Context, secrets ConnectionSecretStore, key string) (string, bool, error) { + exists, err := secrets.Exists(ctx, key) + if err != nil || !exists { + return "", false, err + } + value, err := secrets.Reveal(ctx, key) + return value, true, err +} + +func (s *Service) compensatePATWrite(ctx context.Context, key, previous string, hadPrevious bool) { + if hadPrevious { + _ = s.connectionSecrets.Set(ctx, key, workspacePATSecretName, previous) + return + } + _ = s.connectionSecrets.Delete(ctx, key) +} + +func (s *Service) DeleteWorkspaceConnection(ctx context.Context, workspaceID string) error { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return ErrGitHubWorkspaceRequired + } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return err + } + if s.store == nil { + return ErrGitHubNotConfigured + } + lock := s.workspaceConnectionMutationLock(workspaceID) + lock.Lock() + defer lock.Unlock() + connection, err := s.store.GetWorkspaceConnection(ctx, workspaceID) + if err != nil { + return err + } + if connection != nil && connection.Source == ConnectionSourcePAT && s.connectionSecrets != nil { + err = s.deleteWorkspacePATConnection(ctx, workspaceID) + } else { + err = s.deleteWorkspaceConnectionMetadata(ctx, workspaceID, connection) + } + if err != nil { + return err + } + if connection != nil && connection.InstallationID != nil { + s.InvalidateAppInstallationCredentials(connection.AppRegistrationID, *connection.InstallationID) + } + s.invalidateWorkspaceCredential(workspaceID) + return nil +} + +func (s *Service) deleteWorkspacePATConnection(ctx context.Context, workspaceID string) error { + secretKey := WorkspacePATSecretKey(workspaceID) + previous, hadPrevious, err := revealOptionalSecret(ctx, s.connectionSecrets, secretKey) + if err != nil { + return fmt.Errorf("load workspace PAT: %w", err) + } + if err := deleteOptionalSecret(ctx, s.connectionSecrets, secretKey); err != nil { + return fmt.Errorf("delete workspace PAT: %w", err) + } + if err := s.store.DeleteWorkspaceConnection(ctx, workspaceID); err != nil { + return errors.Join(err, restoreOptionalSecret( + ctx, s.connectionSecrets, secretKey, workspacePATSecretName, previous, hadPrevious, + )) + } + return nil +} + +func (s *Service) deleteWorkspaceConnectionMetadata( + ctx context.Context, workspaceID string, connection *WorkspaceConnection, +) error { + return s.applyAutomationTransition(ctx, connection, nil, func() error { + return s.store.DeleteWorkspaceConnection(ctx, workspaceID) + }) +} + +func (s *Service) workspaceConnectionMutationLock(workspaceID string) *sync.Mutex { + var hash uint32 + for index := range len(workspaceID) { + hash = hash*33 + uint32(workspaceID[index]) + } + return &s.connectionMutationLocks[hash%uint32(len(s.connectionMutationLocks))] +} + +func deleteOptionalSecret(ctx context.Context, secrets ConnectionSecretStore, key string) error { + if secrets == nil { + return nil + } + exists, err := secrets.Exists(ctx, key) + if err != nil || !exists { + return err + } + return secrets.Delete(ctx, key) +} + +func (s *Service) invalidateWorkspaceCredential(workspaceID string) { + if s.resolver != nil { + s.resolver.InvalidateWorkspace(workspaceID) + } + // Existing caches are still global during the compatibility phase. Clear + // them on replacement until Task 05 prefixes every key by principal. + s.clearAuthCaches() +} + +func (s *Service) clearAuthCaches() { + for _, cache := range []*ttlCache{ + s.searchCache, s.prStatusCache, s.prFeedbackCache, s.mergeMethodsCache, + s.accessibleReposCache, s.repoErrorCache, + } { + if cache != nil { + cache.clear() + } + } + if s.protectionCache != nil { + s.protectionCache.clear() + } +} diff --git a/apps/backend/internal/github/service_connections_test.go b/apps/backend/internal/github/service_connections_test.go new file mode 100644 index 0000000000..fdb07ca73f --- /dev/null +++ b/apps/backend/internal/github/service_connections_test.go @@ -0,0 +1,457 @@ +package github + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/kandev/kandev/internal/auth/authn" +) + +type fakeConnectionSecrets struct { + values map[string]string + setErr error + setErrKey string + deleteErr error + deleteErrKey string + deleteThenError bool +} + +func newFakeConnectionSecrets() *fakeConnectionSecrets { + return &fakeConnectionSecrets{values: make(map[string]string)} +} + +func (f *fakeConnectionSecrets) Reveal(_ context.Context, id string) (string, error) { + value, ok := f.values[id] + if !ok { + return "", errors.New("secret not found") + } + return value, nil +} + +func (f *fakeConnectionSecrets) Set(_ context.Context, id, _ string, value string) error { + if f.setErr != nil && (f.setErrKey == "" || f.setErrKey == id) { + return f.setErr + } + f.values[id] = value + return nil +} + +func (f *fakeConnectionSecrets) Delete(_ context.Context, id string) error { + if f.deleteErr != nil && (f.deleteErrKey == "" || f.deleteErrKey == id) { + if f.deleteThenError { + delete(f.values, id) + } + return f.deleteErr + } + delete(f.values, id) + return nil +} + +func (f *fakeConnectionSecrets) Exists(_ context.Context, id string) (bool, error) { + _, ok := f.values[id] + return ok, nil +} + +func (f *fakeConnectionSecrets) ListIDs(context.Context) ([]string, error) { + ids := make([]string, 0, len(f.values)) + for id := range f.values { + ids = append(ids, id) + } + return ids, nil +} + +func newWorkspaceConnectionService(t *testing.T, login string) (*Service, *fakeConnectionSecrets) { + t.Helper() + store := newTestStore(t) + if _, err := store.db.Exec(`CREATE TABLE IF NOT EXISTS workspaces (id TEXT PRIMARY KEY)`); err != nil { + t.Fatalf("create workspaces table: %v", err) + } + if _, err := store.db.Exec(`INSERT INTO workspaces (id) VALUES ('ws-1')`); err != nil { + t.Fatalf("seed workspace: %v", err) + } + service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + secrets := newFakeConnectionSecrets() + service.SetConnectionSecretStore(secrets) + if err := store.UpsertDeploymentAppRegistration(context.Background(), + newAppRegistration("registration-test", 101, "Test App", time.Now().UTC())); err != nil { + t.Fatalf("seed App registration: %v", err) + } + service.tokenClientFactory = func(string) Client { + client := NewMockClient() + client.SetUser(login) + return client + } + return service, secrets +} + +func TestSetWorkspaceConnectionPATIsScopedAndValidatedBeforeWrite(t *testing.T) { + service, secrets := newWorkspaceConnectionService(t, "octocat") + connection, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{ + Source: ConnectionSourcePAT, + Token: "ghp_workspace", + }) + if err != nil { + t.Fatalf("SetWorkspaceConnection: %v", err) + } + if connection.Login != "octocat" || connection.CredentialGeneration != 1 { + t.Fatalf("connection = %+v", connection) + } + if got := secrets.values[WorkspacePATSecretKey("ws-1")]; got != "ghp_workspace" { + t.Fatalf("stored PAT = %q", got) + } +} + +func TestWorkspaceAuthStatusIncludesEffectiveActorsAndCapabilities(t *testing.T) { + service, _ := newWorkspaceConnectionService(t, "octocat") + if _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{ + Source: ConnectionSourcePAT, + Token: "ghp_workspace", + }); err != nil { + t.Fatalf("SetWorkspaceConnection: %v", err) + } + + status, err := service.GetWorkspaceAuthStatus(context.Background(), "ws-1", DefaultUserID) + if err != nil { + t.Fatalf("GetWorkspaceAuthStatus: %v", err) + } + if !status.Authenticated || status.AuthMethod != string(ConnectionSourcePAT) || + !status.TokenConfigured || status.Username != "octocat" { + t.Fatalf("unexpected compatibility status: %+v", status) + } + if status.Automation == nil || status.Automation.Actor == nil || + status.Automation.Actor.Login != "octocat" || status.Automation.Actor.Kind != AuthPrincipalHuman { + t.Fatalf("unexpected automation actor: %+v", status.Automation) + } + if len(status.Automation.MissingCapabilities) != 0 || + !status.Automation.Capabilities[CapabilityPullRequestWrite] { + t.Fatalf("unexpected capabilities: %+v", status.Automation) + } + if status.EffectivePersonalActor == nil || status.EffectivePersonalActor.Login != "octocat" || + status.EffectiveManualMutationActor == nil || status.EffectiveManualMutationActor.Login != "octocat" { + t.Fatalf("unexpected effective actors: personal=%+v mutation=%+v", + status.EffectivePersonalActor, status.EffectiveManualMutationActor) + } +} + +func TestSetWorkspaceConnectionCLIStoresSelectionNotToken(t *testing.T) { + service, secrets := newWorkspaceConnectionService(t, "work-user") + service.resolver.ghToken = func(_ context.Context, host, login string) (string, error) { + if host != "github.com" || login != "work-user" { + t.Fatalf("selected account = %s@%s", login, host) + } + return "cli-derived-token", nil + } + connection, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{ + Source: ConnectionSourceGHCLI, + Host: "github.com", + Login: "work-user", + }) + if err != nil { + t.Fatalf("SetWorkspaceConnection: %v", err) + } + if connection.Source != ConnectionSourceGHCLI || connection.Login != "work-user" { + t.Fatalf("connection = %+v", connection) + } + if len(secrets.values) != 0 { + t.Fatalf("CLI token was persisted: %#v", secrets.values) + } +} + +func TestSetWorkspaceConnectionCLIDeniesMembersButAllowsAdminAndSyntheticIdentity(t *testing.T) { + service, _ := newWorkspaceConnectionService(t, "work-user") + service.resolver.ghToken = func(_ context.Context, _, _ string) (string, error) { + return "cli-derived-token", nil + } + request := SetWorkspaceConnectionRequest{ + Source: ConnectionSourceGHCLI, Host: "github.com", Login: "work-user", + } + + memberContext := authn.WithIdentity(context.Background(), authn.Identity{ + UserID: "member-1", Role: authn.RoleMember, + }) + if _, err := service.SetWorkspaceConnection(memberContext, "ws-1", request); !errors.Is(err, ErrGHCLIOperatorRequired) { + t.Fatalf("member error = %v, want operator denial", err) + } + + adminContext := authn.WithIdentity(context.Background(), authn.Identity{ + UserID: "admin-1", Role: authn.RoleAdmin, + }) + if _, err := service.SetWorkspaceConnection(adminContext, "ws-1", request); err != nil { + t.Fatalf("admin SetWorkspaceConnection() error = %v", err) + } + + syntheticContext := authn.WithIdentity(context.Background(), authn.Identity{ + UserID: DefaultUserID, Role: authn.RoleAdmin, Synthetic: true, + }) + if _, err := service.SetWorkspaceConnection(syntheticContext, "ws-1", request); err != nil { + t.Fatalf("synthetic SetWorkspaceConnection() error = %v", err) + } +} + +func TestSetWorkspaceConnectionValidationFailurePreservesPreviousPAT(t *testing.T) { + service, secrets := newWorkspaceConnectionService(t, "octocat") + if _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{ + Source: ConnectionSourcePAT, Token: "first", + }); err != nil { + t.Fatal(err) + } + service.tokenClientFactory = func(string) Client { return &NoopClient{} } + if _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{ + Source: ConnectionSourcePAT, Token: "invalid", + }); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("error = %v, want ErrInvalidToken", err) + } + if got := secrets.values[WorkspacePATSecretKey("ws-1")]; got != "first" { + t.Fatalf("previous PAT changed to %q", got) + } + connection, err := service.store.GetWorkspaceConnection(context.Background(), "ws-1") + if err != nil || connection.CredentialGeneration != 1 { + t.Fatalf("connection after failure = %+v, err %v", connection, err) + } +} + +func TestDeleteWorkspaceConnectionRemovesOwnedPAT(t *testing.T) { + service, secrets := newWorkspaceConnectionService(t, "octocat") + if _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{ + Source: ConnectionSourcePAT, Token: "token", + }); err != nil { + t.Fatal(err) + } + if err := service.DeleteWorkspaceConnection(context.Background(), "ws-1"); err != nil { + t.Fatalf("DeleteWorkspaceConnection: %v", err) + } + if _, ok := secrets.values[WorkspacePATSecretKey("ws-1")]; ok { + t.Fatal("workspace PAT remains after disconnect") + } +} + +func TestDeleteWorkspaceConnectionPreservesPATConnectionWhenSecretDeleteFails(t *testing.T) { + service, secrets := newWorkspaceConnectionService(t, "octocat") + if _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{ + Source: ConnectionSourcePAT, Token: "token", + }); err != nil { + t.Fatal(err) + } + secrets.deleteErr = errors.New("secret store unavailable") + secrets.deleteErrKey = WorkspacePATSecretKey("ws-1") + + if err := service.DeleteWorkspaceConnection(context.Background(), "ws-1"); err == nil { + t.Fatal("expected disconnect failure") + } + connection, err := service.store.GetWorkspaceConnection(context.Background(), "ws-1") + if err != nil || connection == nil || connection.Source != ConnectionSourcePAT { + t.Fatalf("connection after failed disconnect = %+v, err %v", connection, err) + } + if got := secrets.values[WorkspacePATSecretKey("ws-1")]; got != "token" { + t.Fatalf("PAT after failed disconnect = %q", got) + } +} + +func TestSetWorkspaceConnectionPATRevokesPersonalAppConnections(t *testing.T) { + service, secrets := newWorkspaceConnectionService(t, "octocat") + installationID := int64(42) + if err := service.store.UpsertWorkspaceConnection(context.Background(), activeAppWorkspace("ws-1", installationID)); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + personal := &UserConnection{ + WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: "registration-test", + GitHubUserID: 7, Login: "human", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + } + if err := service.personalConnections.ReplacePersonalConnection(context.Background(), personal, GitHubOAuthTokens{ + AccessToken: "personal-access", RefreshToken: "personal-refresh", AccessExpiresAt: personal.AccessExpiresAt, + }, 0); err != nil { + t.Fatal(err) + } + + if _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{ + Source: ConnectionSourcePAT, Token: "workspace-token", + }); err != nil { + t.Fatal(err) + } + stored, err := service.store.GetUserConnection(context.Background(), "ws-1", "user-1") + if err != nil || stored != nil { + t.Fatalf("personal connection after App replacement = %+v, err %v", stored, err) + } + if _, ok := secrets.values[UserAccessTokenSecretKey("ws-1", "user-1")]; ok { + t.Fatal("personal access secret remains after App replacement") + } + if _, ok := secrets.values[UserRefreshTokenSecretKey("ws-1", "user-1")]; ok { + t.Fatal("personal refresh secret remains after App replacement") + } +} + +func TestDeleteWorkspaceAppConnectionRevokesPersonalConnections(t *testing.T) { + service, secrets := newWorkspaceConnectionService(t, "octocat") + installationID := int64(42) + if err := service.store.UpsertWorkspaceConnection(context.Background(), activeAppWorkspace("ws-1", installationID)); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + personal := &UserConnection{ + WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: "registration-test", + GitHubUserID: 7, Login: "human", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + } + if err := service.personalConnections.ReplacePersonalConnection(context.Background(), personal, GitHubOAuthTokens{ + AccessToken: "personal-access", RefreshToken: "personal-refresh", AccessExpiresAt: personal.AccessExpiresAt, + }, 0); err != nil { + t.Fatal(err) + } + + if err := service.DeleteWorkspaceConnection(context.Background(), "ws-1"); err != nil { + t.Fatal(err) + } + stored, err := service.store.GetUserConnection(context.Background(), "ws-1", "user-1") + if err != nil || stored != nil || len(secrets.values) != 0 { + t.Fatalf("personal state after App disconnect = %+v, secrets=%#v, err %v", stored, secrets.values, err) + } +} + +func TestFailedWorkspaceAppTransitionRestoresPersonalConnection(t *testing.T) { + service, secrets := newWorkspaceConnectionService(t, "octocat") + ctx := context.Background() + installationID := int64(42) + if err := service.store.UpsertWorkspaceConnection(ctx, activeAppWorkspace("ws-1", installationID)); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + personal := &UserConnection{ + WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: "registration-test", + GitHubUserID: 7, Login: "human", Status: ConnectionStatusActive, + AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + } + if err := service.personalConnections.ReplacePersonalConnection(ctx, personal, GitHubOAuthTokens{ + AccessToken: "personal-access", RefreshToken: "personal-refresh", + AccessExpiresAt: personal.AccessExpiresAt, + }, 0); err != nil { + t.Fatal(err) + } + + err := (&serviceAppConnectionStore{service: service}).UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ + WorkspaceID: "ws-1", Source: ConnectionSourcePAT, GitHubHost: defaultGitHubHost, + Status: ConnectionStatusActive, + }) + if err == nil { + t.Fatal("invalid workspace transition unexpectedly succeeded") + } + workspace, getErr := service.store.GetWorkspaceConnection(ctx, "ws-1") + if getErr != nil || workspace == nil || workspace.AppRegistrationID != "registration-test" { + t.Fatalf("workspace after failed transition = %+v, err %v", workspace, getErr) + } + stored, getErr := service.store.GetUserConnection(ctx, "ws-1", "user-1") + if getErr != nil || stored == nil || stored.AppRegistrationID != "registration-test" { + t.Fatalf("personal connection after failed transition = %+v, err %v", stored, getErr) + } + if secrets.values[UserAccessTokenSecretKey("ws-1", "user-1")] != "personal-access" || + secrets.values[UserRefreshTokenSecretKey("ws-1", "user-1")] != "personal-refresh" { + t.Fatalf("personal secrets after failed transition = %#v", secrets.values) + } +} + +func TestDeleteWorkspaceAppConnectionCompensatesPersonalRevokeFailure(t *testing.T) { + service, secrets := newWorkspaceConnectionService(t, "octocat") + installationID := int64(42) + if err := service.store.UpsertWorkspaceConnection(context.Background(), activeAppWorkspace("ws-1", installationID)); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + personal := &UserConnection{ + WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: "registration-test", + GitHubUserID: 7, Login: "human", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + } + if err := service.personalConnections.ReplacePersonalConnection(context.Background(), personal, GitHubOAuthTokens{ + AccessToken: "personal-access", RefreshToken: "personal-refresh", AccessExpiresAt: personal.AccessExpiresAt, + }, 0); err != nil { + t.Fatal(err) + } + secrets.deleteErr = errors.New("personal secret delete failed") + secrets.deleteErrKey = UserRefreshTokenSecretKey("ws-1", "user-1") + + if err := service.DeleteWorkspaceConnection(context.Background(), "ws-1"); err == nil { + t.Fatal("expected disconnect failure") + } + workspace, err := service.store.GetWorkspaceConnection(context.Background(), "ws-1") + if err != nil || workspace == nil || workspace.InstallationID == nil || *workspace.InstallationID != installationID { + t.Fatalf("workspace connection after compensation = %+v, err %v", workspace, err) + } + stored, err := service.store.GetUserConnection(context.Background(), "ws-1", "user-1") + if err != nil || stored == nil { + t.Fatalf("personal connection after compensation = %+v, err %v", stored, err) + } +} + +func TestDifferentAppInstallationRequiresPersonalConnectionRevocation(t *testing.T) { + service, _ := newWorkspaceConnectionService(t, "octocat") + oldInstallationID, newInstallationID := int64(42), int64(84) + existing := activeAppWorkspace("ws-1", oldInstallationID) + if err := service.store.UpsertWorkspaceConnection(context.Background(), existing); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + personal := &UserConnection{ + WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: "registration-test", + GitHubUserID: 7, Login: "human", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, + } + if err := service.personalConnections.ReplacePersonalConnection(context.Background(), personal, GitHubOAuthTokens{ + AccessToken: "personal-access", RefreshToken: "personal-refresh", AccessExpiresAt: personal.AccessExpiresAt, + }, 0); err != nil { + t.Fatal(err) + } + replacement := activeAppWorkspace("ws-1", newInstallationID) + if err := service.revokePersonalForAutomationTransition(context.Background(), existing, replacement); err != nil { + t.Fatal(err) + } + stored, err := service.store.GetUserConnection(context.Background(), "ws-1", "user-1") + if err != nil || stored != nil { + t.Fatalf("personal connection after installation replacement = %+v, err %v", stored, err) + } +} + +func TestSetWorkspaceConnectionRejectsStaleValidatedMutation(t *testing.T) { + service, secrets := newWorkspaceConnectionService(t, "octocat") + started := make(chan struct{}) + release := make(chan struct{}) + service.tokenClientFactory = func(token string) Client { + if token == "slow" { + close(started) + <-release + } + client := NewMockClient() + client.SetUser("octocat") + return client + } + slowResult := make(chan error, 1) + go func() { + _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{ + Source: ConnectionSourcePAT, Token: "slow", + }) + slowResult <- err + }() + <-started + if _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{ + Source: ConnectionSourcePAT, Token: "newer", + }); err != nil { + t.Fatal(err) + } + close(release) + if err := <-slowResult; !errors.Is(err, ErrWorkspaceConnectionStale) { + t.Fatalf("slow mutation error = %v, want stale", err) + } + if got := secrets.values[WorkspacePATSecretKey("ws-1")]; got != "newer" { + t.Fatalf("PAT after stale mutation = %q", got) + } +} + +func TestWorkspaceConnectionStaleUsesConflictResponse(t *testing.T) { + status, code := githubAuthErrorResponse(ErrWorkspaceConnectionStale) + if status != http.StatusConflict || code != "github_connection_changed" { + t.Fatalf("stale response = %d, %q", status, code) + } +} diff --git a/apps/backend/internal/github/service_git_transport.go b/apps/backend/internal/github/service_git_transport.go new file mode 100644 index 0000000000..6e5aebe78e --- /dev/null +++ b/apps/backend/internal/github/service_git_transport.go @@ -0,0 +1,37 @@ +package github + +import ( + "context" + "fmt" + "strings" +) + +// ResolveGitCredential resolves the workspace automation credential for a +// repository transport operation. Personal credentials are never considered. +func (s *Service) ResolveGitCredential( + ctx context.Context, + workspaceID, provider, owner, repo string, +) (string, string, error) { + if !strings.EqualFold(strings.TrimSpace(provider), "github") { + return "", "", fmt.Errorf("unsupported Git credential provider %q", provider) + } + if s == nil || s.resolver == nil { + return "", "", ErrGitHubNotConfigured + } + resolved, err := s.resolver.Resolve(ctx, ResolveCredentialRequest{ + WorkspaceID: workspaceID, + Purpose: CredentialPurposeGitTransport, + RepoOwner: owner, + RepoName: repo, + }) + if err != nil { + return "", "", err + } + if resolved == nil || strings.TrimSpace(resolved.credential) == "" { + return "", "", ErrGitHubNotConfigured + } + if resolved.Principal.Kind == AuthPrincipalApp && !resolved.Capabilities[CapabilityGitRead] { + return "", "", fmt.Errorf("%w: %s", ErrGitHubCapabilityDenied, CapabilityGitRead) + } + return gitHubTokenUsername, resolved.credential, nil +} diff --git a/apps/backend/internal/github/service_git_transport_test.go b/apps/backend/internal/github/service_git_transport_test.go new file mode 100644 index 0000000000..85712900e0 --- /dev/null +++ b/apps/backend/internal/github/service_git_transport_test.go @@ -0,0 +1,76 @@ +package github + +import ( + "context" + "errors" + "testing" +) + +func TestResolveGitCredentialUsesWorkspacePAT(t *testing.T) { + t.Parallel() + + connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + "workspace-a": { + WorkspaceID: "workspace-a", Source: ConnectionSourcePAT, + Status: ConnectionStatusActive, CredentialGeneration: 1, + }, + }} + resolver := NewCredentialResolver(connections, fakeAuthSecrets{ + WorkspacePATSecretKey("workspace-a"): "workspace-a-token", + }) + service := &Service{resolver: resolver} + + username, password, err := service.ResolveGitCredential( + context.Background(), "workspace-a", "github", "acme", "private", + ) + if err != nil { + t.Fatalf("ResolveGitCredential(): %v", err) + } + if username != "x-access-token" || password != "workspace-a-token" { + t.Fatalf("credential = %q/%q, want x-access-token/workspace-a-token", username, password) + } +} + +func TestResolveGitCredentialRejectsAppWithoutGitRead(t *testing.T) { + t.Parallel() + + installationID := int64(42) + connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + "workspace-a": { + WorkspaceID: "workspace-a", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, AppRegistrationID: "registration-test", + Status: ConnectionStatusActive, CredentialGeneration: 1, + }, + }} + resolver := NewCredentialResolver(connections, nil) + resolver.SetAutomationProvider(staticTransportCredentialProvider{resolved: &ResolvedCredential{ + Client: NewMockClient(), + credential: "app-token", + Principal: AuthPrincipal{ + Kind: AuthPrincipalApp, Source: ConnectionSourceGitHubAppInstallation, + AppRegistrationID: "registration-test", + }, + AppRegistrationID: "registration-test", + Capabilities: map[GitHubAppCapability]bool{}, + }}) + service := &Service{resolver: resolver} + + _, _, err := service.ResolveGitCredential( + context.Background(), "workspace-a", "github", "acme", "private", + ) + if !errors.Is(err, ErrGitHubCapabilityDenied) { + t.Fatalf("ResolveGitCredential() error = %v, want capability denied", err) + } +} + +type staticTransportCredentialProvider struct { + resolved *ResolvedCredential +} + +func (p staticTransportCredentialProvider) ResolveAutomation( + context.Context, + *WorkspaceConnection, + ResolveCredentialRequest, +) (*ResolvedCredential, error) { + return p.resolved, nil +} diff --git a/apps/backend/internal/github/service_issues.go b/apps/backend/internal/github/service_issues.go index bf7af817c8..ff09e26277 100644 --- a/apps/backend/internal/github/service_issues.go +++ b/apps/backend/internal/github/service_issues.go @@ -16,6 +16,31 @@ import ( // CreateIssueWatch creates a new issue watch and triggers an initial poll. func (s *Service) CreateIssueWatch(ctx context.Context, req *CreateIssueWatchRequest) (*IssueWatch, error) { + return s.createIssueWatch(ctx, req) +} + +func (s *Service) CreateIssueWatchForWorkspace( + ctx context.Context, req *CreateIssueWatchRequest, +) (*IssueWatch, error) { + if req == nil || strings.TrimSpace(req.WorkspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + if err := s.authorizeWorkspaceAccess(ctx, req.WorkspaceID); err != nil { + return nil, err + } + if _, err := s.resolveAutomationClient(ctx, req.WorkspaceID, "", ""); err != nil { + return nil, err + } + return s.createIssueWatch(ctx, req) +} + +func (s *Service) createIssueWatch(ctx context.Context, req *CreateIssueWatchRequest) (*IssueWatch, error) { + if req == nil || strings.TrimSpace(req.WorkspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + if err := s.authorizeWorkspaceAccess(ctx, req.WorkspaceID); err != nil { + return nil, err + } if req.PollIntervalSeconds <= 0 { req.PollIntervalSeconds = defaultWatchPollIntervalSec } @@ -74,11 +99,21 @@ func (s *Service) initialIssueCheck(ctx context.Context, watch *IssueWatch) { // GetIssueWatch returns a single issue watch by ID. func (s *Service) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) { - return s.store.GetIssueWatch(ctx, id) + watch, err := s.store.GetIssueWatch(ctx, id) + if err != nil || watch == nil { + return watch, err + } + if err := s.authorizeWorkspaceAccess(ctx, watch.WorkspaceID); err != nil { + return nil, err + } + return watch, nil } // ListIssueWatches returns all issue watches for a workspace. func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) { + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } return s.store.ListIssueWatches(ctx, workspaceID) } @@ -105,6 +140,9 @@ func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIs if iw == nil { return fmt.Errorf("issue watch not found: %s", id) } + if err := s.authorizeWorkspaceAccess(ctx, iw.WorkspaceID); err != nil { + return err + } if req.WorkflowID != nil { iw.WorkflowID = *req.WorkflowID } @@ -157,6 +195,15 @@ func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIs // //nolint:nestif // mirrors DeleteReviewWatch shape func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error { + watch, err := s.store.GetIssueWatch(ctx, id) + if err != nil { + return err + } + if watch != nil { + if err := s.authorizeWorkspaceAccess(ctx, watch.WorkspaceID); err != nil { + return err + } + } if s.taskDeleter != nil { issueTasks, err := s.store.ListIssueWatchTasksByWatch(ctx, id) if err != nil { @@ -182,8 +229,12 @@ func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error { // CheckIssueWatch checks for new issues matching the watch and returns ones not yet tracked. func (s *Service) CheckIssueWatch(ctx context.Context, watch *IssueWatch) ([]*Issue, error) { - if s.client == nil { - return nil, fmt.Errorf("github client not available") + if watch == nil || strings.TrimSpace(watch.WorkspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + resolved, err := s.resolveAutomationClient(ctx, watch.WorkspaceID, "", "") + if err != nil { + return nil, err } s.logger.Debug("checking issue watch for new issues", @@ -203,7 +254,7 @@ func (s *Service) CheckIssueWatch(ctx context.Context, watch *IssueWatch) ([]*Is if len(fetchWatch.Repos) == 0 { fetchWatch.Repos = workspaceScopeRepoFilters(settings) } - issues, err := s.fetchIssues(ctx, &fetchWatch) + issues, err := s.fetchIssues(ctx, resolved.Client, &fetchWatch) if err != nil { return nil, err } @@ -229,15 +280,15 @@ func (s *Service) CheckIssueWatch(ctx context.Context, watch *IssueWatch) ([]*Is } // fetchIssues fetches issues based on the watch configuration. -func (s *Service) fetchIssues(ctx context.Context, watch *IssueWatch) ([]*Issue, error) { +func (s *Service) fetchIssues(ctx context.Context, client Client, watch *IssueWatch) ([]*Issue, error) { hasRepos := len(watch.Repos) > 0 if !hasRepos { filter := s.buildIssueFilter(watch) - return s.client.ListIssues(ctx, filter, watch.CustomQuery) + return client.ListIssues(ctx, filter, watch.CustomQuery) } - return s.fetchIssuesWithRepoFilter(ctx, watch), nil + return s.fetchIssuesWithRepoFilter(ctx, client, watch), nil } // buildIssueFilter builds the filter qualifier from watch labels. `state:open` @@ -257,7 +308,7 @@ func (s *Service) buildIssueFilter(watch *IssueWatch) string { } // fetchIssuesWithRepoFilter queries each repo individually and deduplicates. -func (s *Service) fetchIssuesWithRepoFilter(ctx context.Context, watch *IssueWatch) []*Issue { +func (s *Service) fetchIssuesWithRepoFilter(ctx context.Context, client Client, watch *IssueWatch) []*Issue { var allIssues []*Issue seen := make(map[string]bool) @@ -274,9 +325,9 @@ func (s *Service) fetchIssuesWithRepoFilter(ctx context.Context, watch *IssueWat var err error if watch.CustomQuery != "" { query := watch.CustomQuery + " " + qualifier - issues, err = s.client.ListIssues(ctx, "", query) + issues, err = client.ListIssues(ctx, "", query) } else { - issues, err = s.client.ListIssues(ctx, filter, "") + issues, err = client.ListIssues(ctx, filter, "") } if err != nil { if isConnectivityError(err) { @@ -325,6 +376,9 @@ func (s *Service) DisableIssueWatchWithError(ctx context.Context, watchID, cause // //nolint:dupl // mirrors TriggerAllReviewChecks — different types, same structure func (s *Service) TriggerAllIssueChecks(ctx context.Context, workspaceID string) (int, error) { + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return 0, err + } watches, err := s.store.ListIssueWatches(ctx, workspaceID) if err != nil { return 0, err diff --git a/apps/backend/internal/github/service_mentions_test.go b/apps/backend/internal/github/service_mentions_test.go index 44210a5cdf..504f7c723c 100644 --- a/apps/backend/internal/github/service_mentions_test.go +++ b/apps/backend/internal/github/service_mentions_test.go @@ -38,6 +38,10 @@ func TestServiceSearchMentionsUsesServerBuiltTitleOnlyQuery(t *testing.T) { prs: []*PR{{ID: 202, NodeID: "PR_kwDOB", Number: 8, Title: "Fix auth flow", RepoOwner: "acme", RepoName: "web"}}, } service := &Service{client: client, searchCache: newTTLCache()} + // Mention search is a workspace-scoped personal-read operation. Seed the + // workspace's human automation identity explicitly rather than relying on + // the legacy, process-wide test client. + configureTestWorkspaceAuth(t, service, client, "workspace-1") issues, err := service.SearchMentionIssuesForWorkspace( context.Background(), diff --git a/apps/backend/internal/github/service_pr.go b/apps/backend/internal/github/service_pr.go index f3b80f84f4..628cc32066 100644 --- a/apps/backend/internal/github/service_pr.go +++ b/apps/backend/internal/github/service_pr.go @@ -20,17 +20,47 @@ func (s *Service) SubmitReview(ctx context.Context, owner, repo string, number i if s.client == nil { return fmt.Errorf("github client not configured") } + return submitReview(ctx, s.client, owner, repo, number, event, body) +} + +// SubmitReviewForWorkspace submits a user-triggered review with an explicitly +// resolved workspace principal. GitHub App fallback is allowed by the resolver +// and remains attributable to the App rather than impersonating the user. +func (s *Service) SubmitReviewForWorkspace( + ctx context.Context, + workspaceID string, + userID string, + owner string, + repo string, + number int, + event string, + body string, +) (AuthPrincipal, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return AuthPrincipal{}, err + } + resolved, err := s.resolvePersonalWriteClient(ctx, workspaceID, userID, owner, repo) + if err != nil { + return AuthPrincipal{}, err + } + if err := requireGitHubCapability(resolved, CapabilityPullRequestWrite); err != nil { + return AuthPrincipal{}, err + } + return resolved.Principal, submitReview(ctx, resolved.Client, owner, repo, number, event, body) +} + +func submitReview(ctx context.Context, client Client, owner, repo string, number int, event, body string) error { if event == reviewEventApprove { - user, userErr := s.client.GetAuthenticatedUser(ctx) + user, userErr := client.GetAuthenticatedUser(ctx) if userErr == nil && user != "" { - pr, prErr := s.client.GetPR(ctx, owner, repo, number) + pr, prErr := client.GetPR(ctx, owner, repo, number) if prErr == nil && pr != nil && strings.EqualFold(strings.TrimSpace(pr.AuthorLogin), strings.TrimSpace(user)) { return ErrSelfApprove } } } - return s.client.SubmitReview(ctx, owner, repo, number, event, body) + return client.SubmitReview(ctx, owner, repo, number, event, body) } // RequestReviewers requests reviews and evicts the affected PR caches so the @@ -39,10 +69,51 @@ func (s *Service) RequestReviewers(ctx context.Context, owner, repo string, numb if s.client == nil { return ErrNoClient } - if err := s.client.RequestReviewers(ctx, owner, repo, number, reviewers); err != nil { + return s.requestReviewersWithClient(ctx, s.client, "legacy", owner, repo, number, reviewers) +} + +// RequestReviewersForWorkspace requests a review using the workspace's +// personal-write routing rules and returns the identity GitHub will attribute. +func (s *Service) RequestReviewersForWorkspace( + ctx context.Context, + workspaceID string, + userID string, + owner string, + repo string, + number int, + reviewers []string, +) (AuthPrincipal, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return AuthPrincipal{}, err + } + resolved, err := s.resolvePersonalWriteClient(ctx, workspaceID, userID, owner, repo) + if err != nil { + return AuthPrincipal{}, err + } + if err := requireGitHubCapability(resolved, CapabilityPullRequestWrite); err != nil { + return AuthPrincipal{}, err + } + return resolved.Principal, s.requestReviewersWithClient( + ctx, resolved.Client, resolved.cacheScopeForPurpose(CredentialPurposePersonalRead), owner, repo, number, reviewers, + ) +} + +func (s *Service) requestReviewersWithClient( + ctx context.Context, + client Client, + cacheScope string, + owner string, + repo string, + number int, + reviewers []string, +) error { + if err := client.RequestReviewers(ctx, owner, repo, number, reviewers); err != nil { return err } - key := prStatusCacheKey(owner, repo, number) + // The legacy API still stores its entries in the explicit legacy cache + // namespace. Keep invalidation in that same generation namespace so a + // successful request cannot leave stale feedback or status behind. + key := scopedCacheKey(cacheScope, prStatusCacheKey(owner, repo, number)) s.prFeedbackCache.invalidateKey(key) s.prStatusCache.invalidateKey(key) return nil @@ -57,6 +128,44 @@ func (s *Service) MergePR(ctx context.Context, owner, repo string, number int, m if s.client == nil { return ErrNoClient } + return s.mergePRWithClient(ctx, s.client, "legacy", owner, repo, number, mergeMethod) +} + +// MergePRForWorkspace performs a user-triggered merge using the workspace's +// personal-write routing rules and returns the actual attributed principal. +func (s *Service) MergePRForWorkspace( + ctx context.Context, + workspaceID string, + userID string, + owner string, + repo string, + number int, + mergeMethod string, +) (AuthPrincipal, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return AuthPrincipal{}, err + } + resolved, err := s.resolvePersonalWriteClient(ctx, workspaceID, userID, owner, repo) + if err != nil { + return AuthPrincipal{}, err + } + if err := requireGitHubCapability(resolved, CapabilityPullRequestWrite); err != nil { + return AuthPrincipal{}, err + } + return resolved.Principal, s.mergePRWithClient( + ctx, resolved.Client, resolved.CacheScope, owner, repo, number, mergeMethod, + ) +} + +func (s *Service) mergePRWithClient( + ctx context.Context, + client Client, + cacheScope string, + owner string, + repo string, + number int, + mergeMethod string, +) error { if mergeMethod == "" { // Resolve to an allowed method up-front so we don't rely on GitHub's // "default to merge" behavior, which 405s on repos that disallow @@ -64,13 +173,13 @@ func (s *Service) MergePR(ctx context.Context, owner, repo string, number int, m // lookup fails (or — degenerate config — reports no method allowed), // fall back to GitHub's default and surface its error rather than // blocking the merge attempt. - if methods, err := s.GetRepoMergeMethods(ctx, owner, repo); err == nil { + if methods, err := s.getRepoMergeMethods(ctx, client, cacheScope, owner, repo); err == nil { if pick := pickDefaultMergeMethod(methods); pick != "" { mergeMethod = pick } } } - return s.client.MergePR(ctx, owner, repo, number, mergeMethod) + return client.MergePR(ctx, owner, repo, number, mergeMethod) } // GetRepoMergeMethods returns the merge methods a repo allows, cached for @@ -79,9 +188,32 @@ func (s *Service) GetRepoMergeMethods(ctx context.Context, owner, repo string) ( if s.client == nil { return RepoMergeMethods{}, ErrNoClient } - key := owner + "/" + repo + return s.getRepoMergeMethods(ctx, s.client, "legacy", owner, repo) +} + +func (s *Service) GetRepoMergeMethodsForWorkspace( + ctx context.Context, workspaceID, userID, owner, repo string, +) (RepoMergeMethods, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return RepoMergeMethods{}, err + } + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) + if err != nil { + return RepoMergeMethods{}, err + } + return s.getRepoMergeMethods(ctx, resolved.Client, resolved.CacheScope, owner, repo) +} + +func (s *Service) getRepoMergeMethods( + ctx context.Context, + client Client, + cacheScope string, + owner string, + repo string, +) (RepoMergeMethods, error) { + key := scopedCacheKey(cacheScope, owner+"/"+repo) v, err := s.mergeMethodsCache.doOrFetch(key, func() (any, error) { - return s.client.GetRepoMergeMethods(ctx, owner, repo) + return client.GetRepoMergeMethods(ctx, owner, repo) }) if err != nil { return RepoMergeMethods{}, err @@ -126,6 +258,22 @@ func (s *Service) GetPR(ctx context.Context, owner, repo string, number int) (*P return getPRAnonymous(ctx, owner, repo, number) } +func (s *Service) GetPRForWorkspace( + ctx context.Context, workspaceID, userID, owner, repo string, number int, +) (*PR, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) + if err != nil { + if errors.Is(err, ErrGitHubNotConfigured) { + return getPRAnonymous(ctx, owner, repo, number) + } + return nil, err + } + return resolved.Client.GetPR(ctx, owner, repo, number) +} + // GetIssue fetches basic issue details from GitHub. The create-task dialog is // currently the only caller and dedupes requests per URL on the frontend. func (s *Service) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) { @@ -138,6 +286,19 @@ func (s *Service) GetIssue(ctx context.Context, owner, repo string, number int) return getIssueAnonymous(ctx, owner, repo, number) } +func (s *Service) GetIssueForWorkspace( + ctx context.Context, workspaceID, userID, owner, repo string, number int, +) (*Issue, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) + if err != nil { + return nil, err + } + return resolved.Client.GetIssue(ctx, owner, repo, number) +} + // GetPRFeedback fetches live PR feedback from GitHub. Cached briefly with // singleflight coalescing: the task page fires this for the same PR many times // in quick succession (chat-bar CI chip, detail panel, hover popover — each on @@ -148,6 +309,25 @@ func (s *Service) GetPRFeedback(ctx context.Context, owner, repo string, number if s.client == nil { return nil, fmt.Errorf("github client not available") } + return s.getPRFeedback(ctx, s.client, "legacy", owner, repo, number) +} + +func (s *Service) GetPRFeedbackForWorkspace( + ctx context.Context, workspaceID, userID, owner, repo string, number int, +) (*PRFeedback, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) + if err != nil { + return nil, err + } + return s.getPRFeedback(ctx, resolved.Client, resolved.CacheScope, owner, repo, number) +} + +func (s *Service) getPRFeedback( + ctx context.Context, client Client, cacheScope, owner, repo string, number int, +) (*PRFeedback, error) { // Detach the upstream call's context from the singleflight leader: a // cancelled leader (user closes the tab) would otherwise return its // context error to all co-waiters, cascading a single client disconnect @@ -158,9 +338,9 @@ func (s *Service) GetPRFeedback(ctx context.Context, owner, repo string, number // consumed past the deadline. fetchCtx, cancelFetch := derivedFetchContext(ctx) defer cancelFetch() - key := prStatusCacheKey(owner, repo, number) + key := scopedCacheKey(cacheScope, prStatusCacheKey(owner, repo, number)) v, err := s.prFeedbackCache.doOrFetch(key, func() (any, error) { - return s.client.GetPRFeedback(fetchCtx, owner, repo, number) + return client.GetPRFeedback(fetchCtx, owner, repo, number) }) if err != nil { return nil, err @@ -176,13 +356,32 @@ func (s *Service) GetPRStatus(ctx context.Context, owner, repo string, number in if s.client == nil { return nil, fmt.Errorf("github client not available") } + return s.getPRStatus(ctx, s.client, "legacy", owner, repo, number) +} + +func (s *Service) GetPRStatusForWorkspace( + ctx context.Context, workspaceID, userID, owner, repo string, number int, +) (*PRStatus, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) + if err != nil { + return nil, err + } + return s.getPRStatus(ctx, resolved.Client, resolved.CacheScope, owner, repo, number) +} + +func (s *Service) getPRStatus( + ctx context.Context, client Client, cacheScope, owner, repo string, number int, +) (*PRStatus, error) { // Detach the upstream call from the singleflight leader's context; see // GetPRFeedback for the cascading-cancel + deadline-preserve rationale. fetchCtx, cancelFetch := derivedFetchContext(ctx) defer cancelFetch() - key := prStatusCacheKey(owner, repo, number) + key := scopedCacheKey(cacheScope, prStatusCacheKey(owner, repo, number)) v, err := s.prStatusCache.doOrFetch(key, func() (any, error) { - return s.client.GetPRStatus(fetchCtx, owner, repo, number) + return client.GetPRStatus(fetchCtx, owner, repo, number) }) if err != nil { return nil, err @@ -227,6 +426,46 @@ func (s *Service) GetPRStatusesBatch(ctx context.Context, refs []PRRef) (map[str if s.client == nil { return nil, fmt.Errorf("github client not available") } + return s.getPRStatusesBatchWithClient(ctx, s.client, "legacy", refs) +} + +func (s *Service) GetPRStatusesBatchForWorkspace( + ctx context.Context, workspaceID, userID string, refs []PRRef, +) (map[string]*PRStatus, error) { + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, "", "") + if err != nil { + return nil, err + } + settings, err := s.GetWorkspaceSettings(ctx, workspaceID) + if err != nil { + return nil, err + } + filtered := make([]PRRef, 0, len(refs)) + for _, ref := range refs { + if repositoryInWorkspaceScope(settings, ref.Owner, ref.Repo) { + filtered = append(filtered, ref) + } + } + visibilityInput := make([]RepoFilter, 0, len(filtered)) + for _, ref := range filtered { + visibilityInput = append(visibilityInput, RepoFilter{Owner: ref.Owner, Name: ref.Repo}) + } + visible, err := s.automationRepositoryVisibility(ctx, workspaceID, visibilityInput) + if err != nil { + return nil, err + } + allowed := filtered[:0] + for _, ref := range filtered { + if visible[repositoryIdentityKey(ref.Owner, ref.Repo)] { + allowed = append(allowed, ref) + } + } + return s.getPRStatusesBatchWithClient(ctx, resolved.Client, resolved.CacheScope, allowed) +} + +func (s *Service) getPRStatusesBatchWithClient( + ctx context.Context, client Client, cacheScope string, refs []PRRef, +) (map[string]*PRStatus, error) { result := make(map[string]*PRStatus, len(refs)) var mu sync.Mutex sem := make(chan struct{}, prStatusBatchConcurrency) @@ -247,7 +486,7 @@ func (s *Service) GetPRStatusesBatch(ctx context.Context, refs []PRRef) (map[str return } defer func() { <-sem }() - status, err := s.GetPRStatus(ctx, r.Owner, r.Repo, r.Number) + status, err := s.getPRStatus(ctx, client, cacheScope, r.Owner, r.Repo, r.Number) if err != nil { s.logger.Debug("batch PR status fetch failed", zap.String("owner", r.Owner), @@ -275,6 +514,19 @@ func (s *Service) GetPRFiles(ctx context.Context, owner, repo string, number int return s.client.ListPRFiles(ctx, owner, repo, number) } +func (s *Service) GetPRFilesForWorkspace( + ctx context.Context, workspaceID, userID, owner, repo string, number int, +) ([]PRFile, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) + if err != nil { + return nil, err + } + return resolved.Client.ListPRFiles(ctx, owner, repo, number) +} + // GetPRCommits fetches commits in a PR from GitHub. func (s *Service) GetPRCommits(ctx context.Context, owner, repo string, number int) ([]PRCommitInfo, error) { if s.client == nil { @@ -283,6 +535,19 @@ func (s *Service) GetPRCommits(ctx context.Context, owner, repo string, number i return s.client.ListPRCommits(ctx, owner, repo, number) } +func (s *Service) GetPRCommitsForWorkspace( + ctx context.Context, workspaceID, userID, owner, repo string, number int, +) ([]PRCommitInfo, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) + if err != nil { + return nil, err + } + return resolved.Client.ListPRCommits(ctx, owner, repo, number) +} + // timeEqual compares two nullable time pointers for equality. func timeEqual(a, b *time.Time) bool { if a == nil && b == nil { diff --git a/apps/backend/internal/github/service_pr_sync_test.go b/apps/backend/internal/github/service_pr_sync_test.go index 3017cfd750..2c4f271467 100644 --- a/apps/backend/internal/github/service_pr_sync_test.go +++ b/apps/backend/internal/github/service_pr_sync_test.go @@ -31,7 +31,7 @@ func TestTriggerPRSync_SyncsExistingWatch(t *testing.T) { PRNumber: 10, Branch: "feat/x", } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatal(err) } tp := &TaskPR{ @@ -82,7 +82,7 @@ func TestTriggerPRSync_DetectsPR(t *testing.T) { PRNumber: 0, Branch: "feat/y", } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatal(err) } @@ -133,7 +133,7 @@ func TestTriggerPRSyncAll_ThrottlesDetectionProbe(t *testing.T) { PRNumber: 0, Branch: "feat/never-merged", } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatal(err) } @@ -179,7 +179,7 @@ func TestTriggerPRDetection_CoalescesConcurrentProbes(t *testing.T) { PRNumber: 0, Branch: "feat/never-merged", } - if err := store.CreatePRWatch(ctx, watch); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(watch)); err != nil { t.Fatal(err) } diff --git a/apps/backend/internal/github/service_pr_watch.go b/apps/backend/internal/github/service_pr_watch.go index 45ccc2d549..721e8dcf3e 100644 --- a/apps/backend/internal/github/service_pr_watch.go +++ b/apps/backend/internal/github/service_pr_watch.go @@ -23,6 +23,24 @@ import ( // its own watch row. Multi-branch tasks store one watch per branch so a // secondary branch's push isn't lost behind the primary's existing watch. func (s *Service) CreatePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string) (*PRWatch, error) { + return s.createPRWatch(ctx, "", sessionID, taskID, repositoryID, owner, repo, prNumber, branch) +} + +func (s *Service) CreatePRWatchForWorkspace( + ctx context.Context, workspaceID, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string, +) (*PRWatch, error) { + if strings.TrimSpace(workspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } + return s.createPRWatch(ctx, workspaceID, sessionID, taskID, repositoryID, owner, repo, prNumber, branch) +} + +func (s *Service) createPRWatch( + ctx context.Context, workspaceID, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string, +) (*PRWatch, error) { // Evict any negative-cache entry up front. Caller intent on either // branch (creating a new watch or re-finding an existing one) is "I // want this repo watched", which means a stale "missing" verdict @@ -37,6 +55,7 @@ func (s *Service) CreatePRWatch(ctx context.Context, sessionID, taskID, reposito return existing, nil // already watching this (session, repo, branch) } w := &PRWatch{ + WorkspaceID: workspaceID, SessionID: sessionID, TaskID: taskID, RepositoryID: repositoryID, @@ -63,6 +82,18 @@ func (s *Service) GetPRWatchBySession(ctx context.Context, sessionID string) (*P return s.store.GetPRWatchBySession(ctx, sessionID) } +// GetPRWatch returns one PR watch after authorizing its stored workspace. +func (s *Service) GetPRWatch(ctx context.Context, id string) (*PRWatch, error) { + watch, err := s.store.GetPRWatch(ctx, id) + if err != nil || watch == nil { + return watch, err + } + if err := s.authorizeWorkspaceAccess(ctx, watch.WorkspaceID); err != nil { + return nil, err + } + return watch, nil +} + // GetPRWatchBySessionAndRepo returns the PR watch for a (session, repo) pair. func (s *Service) GetPRWatchBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*PRWatch, error) { return s.store.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID) @@ -90,8 +121,26 @@ func (s *Service) ListActivePRWatches(ctx context.Context) ([]*PRWatch, error) { return s.store.ListActivePRWatches(ctx) } +// ListActivePRWatchesForWorkspace returns active PR watches for one authorized +// workspace. The all-workspaces variant is reserved for identity-less pollers. +func (s *Service) ListActivePRWatchesForWorkspace(ctx context.Context, workspaceID string) ([]*PRWatch, error) { + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } + return s.store.ListActivePRWatchesForWorkspace(ctx, workspaceID) +} + // DeletePRWatch deletes a PR watch by ID. func (s *Service) DeletePRWatch(ctx context.Context, id string) error { + watch, err := s.store.GetPRWatch(ctx, id) + if err != nil { + return err + } + if watch != nil { + if err := s.authorizeWorkspaceAccess(ctx, watch.WorkspaceID); err != nil { + return err + } + } return s.store.DeletePRWatch(ctx, id) } @@ -116,7 +165,24 @@ func (s *Service) CheckPRWatch(ctx context.Context, watch *PRWatch) (*PRStatus, if s.client == nil { return nil, false, fmt.Errorf("github client not available") } - status, err := s.client.GetPRStatus(ctx, watch.Owner, watch.Repo, watch.PRNumber) + return s.checkPRWatchWithClient(ctx, s.client, watch) +} + +func (s *Service) CheckPRWatchForWorkspace(ctx context.Context, watch *PRWatch) (*PRStatus, bool, error) { + if watch == nil || strings.TrimSpace(watch.WorkspaceID) == "" { + return nil, false, ErrGitHubWorkspaceRequired + } + resolved, err := s.resolveAutomationClient(ctx, watch.WorkspaceID, watch.Owner, watch.Repo) + if err != nil { + return nil, false, err + } + return s.checkPRWatchWithClient(ctx, resolved.Client, watch) +} + +func (s *Service) checkPRWatchWithClient( + ctx context.Context, client Client, watch *PRWatch, +) (*PRStatus, bool, error) { + status, err := client.GetPRStatus(ctx, watch.Owner, watch.Repo, watch.PRNumber) if err != nil { return nil, false, err } @@ -164,6 +230,21 @@ func prWatchFeedbackWatermark(watch *PRWatch, status *PRStatus) *time.Time { // so each branch gets its own watch — keying on (session, repo) alone // drops secondary branches' watches behind the primary's existing row. func (s *Service) EnsurePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo, branch string) (*PRWatch, error) { + return s.ensurePRWatch(ctx, "", sessionID, taskID, repositoryID, owner, repo, branch) +} + +func (s *Service) EnsurePRWatchForWorkspace( + ctx context.Context, workspaceID, sessionID, taskID, repositoryID, owner, repo, branch string, +) (*PRWatch, error) { + if strings.TrimSpace(workspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + return s.ensurePRWatch(ctx, workspaceID, sessionID, taskID, repositoryID, owner, repo, branch) +} + +func (s *Service) ensurePRWatch( + ctx context.Context, workspaceID, sessionID, taskID, repositoryID, owner, repo, branch string, +) (*PRWatch, error) { // Same up-front eviction as CreatePRWatch — see that function for the // rationale. The existing-watch early-return path must not inherit a // stale "missing" verdict from a prior incarnation. @@ -176,6 +257,7 @@ func (s *Service) EnsurePRWatch(ctx context.Context, sessionID, taskID, reposito return existing, nil } w := &PRWatch{ + WorkspaceID: workspaceID, SessionID: sessionID, TaskID: taskID, RepositoryID: repositoryID, @@ -202,6 +284,21 @@ func (s *Service) EnsurePRWatch(ctx context.Context, sessionID, taskID, reposito // callers MUST pass it — empty causes ReplaceTaskPR to wipe the entire task's // PR rows (legacy "delete all" branch), which is what older code relied on. func (s *Service) AssociatePRWithTask(ctx context.Context, taskID, repositoryID string, pr *PR) (*TaskPR, error) { + return s.associatePRWithTask(ctx, "", taskID, repositoryID, pr) +} + +func (s *Service) AssociatePRWithTaskForWorkspace( + ctx context.Context, workspaceID, taskID, repositoryID string, pr *PR, +) (*TaskPR, error) { + if strings.TrimSpace(workspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + return s.associatePRWithTask(ctx, workspaceID, taskID, repositoryID, pr) +} + +func (s *Service) associatePRWithTask( + ctx context.Context, workspaceID, taskID, repositoryID string, pr *PR, +) (*TaskPR, error) { // Multi-branch: scope the "already-current" short-circuit by exact // pr_number too. A task can hold multiple PR rows per (task, repo) on // different branches; the legacy by-repo lookup returns whichever row @@ -217,6 +314,7 @@ func (s *Service) AssociatePRWithTask(ctx context.Context, taskID, repositoryID return existing, nil } tp := &TaskPR{ + WorkspaceID: workspaceID, TaskID: taskID, RepositoryID: repositoryID, Owner: pr.RepoOwner, @@ -289,6 +387,31 @@ func (s *Service) AssociateExistingPRByURL(ctx context.Context, taskID, reposito return tp, nil } +func (s *Service) AssociateExistingPRByURLForWorkspace( + ctx context.Context, workspaceID, userID, taskID, repositoryID, prURL string, +) (*TaskPR, error) { + owner, repo, prNumber, err := parsePRURL(prURL) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidPRURL, err) + } + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) + if err != nil { + return nil, err + } + pr, err := resolved.Client.GetPR(ctx, owner, repo, prNumber) + if err != nil { + return nil, fmt.Errorf("fetch PR: %w", err) + } + tp, err := s.AssociatePRWithTaskForWorkspace(ctx, workspaceID, taskID, repositoryID, pr) + if err != nil { + return nil, fmt.Errorf("associate PR with task: %w", err) + } + return tp, nil +} + // AssociatePRByURL parses a GitHub PR URL, fetches the PR data, creates a PR // watch, and associates it with the given task. Called after the user // creates a PR from the UI. `repositoryID` scopes the watch + association to @@ -328,6 +451,38 @@ func (s *Service) AssociatePRByURL(ctx context.Context, sessionID, taskID, repos } } +func (s *Service) AssociatePRByURLForWorkspace( + ctx context.Context, workspaceID, userID, sessionID, taskID, repositoryID, prURL, branch string, +) error { + owner, repo, prNumber, err := parsePRURL(prURL) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidPRURL, err) + } + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return err + } + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) + if err != nil { + return err + } + pr, err := resolved.Client.GetPR(ctx, owner, repo, prNumber) + if err != nil { + return fmt.Errorf("fetch PR: %w", err) + } + if branch == "" { + branch = pr.HeadBranch + } + if _, err := s.CreatePRWatchForWorkspace( + ctx, workspaceID, sessionID, taskID, repositoryID, owner, repo, prNumber, branch, + ); err != nil { + return fmt.Errorf("create PR watch: %w", err) + } + if _, err := s.AssociatePRWithTaskForWorkspace(ctx, workspaceID, taskID, repositoryID, pr); err != nil { + return fmt.Errorf("associate PR with task: %w", err) + } + return nil +} + // parsePRURL extracts owner, repo, and PR number from a GitHub PR URL. // Expected format: https://github.com/{owner}/{repo}/pull/{number} // Handles trailing slashes, query parameters, and URL fragments. @@ -467,7 +622,7 @@ func (s *Service) refreshStaleWorkspaceWatches(workspaceID string, staleTasks ma if len(allWatches) == 0 { return } - if _, err := s.SyncWatchesBatched(syncCtx, allWatches); err != nil { + if _, err := s.SyncWorkspaceWatchesBatched(syncCtx, workspaceID, allWatches); err != nil { // Batched fetch failed (noop client, auth blip, GraphQL error). // Fall back to per-task sync with bounded concurrency so we // don't spawn one gh per watch in lockstep. @@ -579,7 +734,7 @@ func (s *Service) prepareTaskPRSyncState(ctx context.Context, tp *TaskPR, status nextRequiredReviews := tp.RequiredReviews if status.RequiredReviews != nil { nextRequiredReviews = status.RequiredReviews - } else if fetched := s.fetchRequiredReviews(ctx, tp.Owner, tp.Repo, nextBaseBranch); fetched != nil { + } else if fetched := s.fetchRequiredReviewsForTaskPR(ctx, tp, nextBaseBranch); fetched != nil { nextRequiredReviews = fetched } // GitHub reports draft status separately from mergeStateStatus and can @@ -664,6 +819,16 @@ func (s *Service) SyncTaskPR(ctx context.Context, taskID string, status *PRStatu return nil } +func (s *Service) fetchRequiredReviewsForTaskPR(ctx context.Context, tp *TaskPR, branch string) *int { + if tp == nil { + return nil + } + if tp.WorkspaceID == "" { + return s.fetchRequiredReviews(ctx, tp.Owner, tp.Repo, branch) + } + return s.fetchRequiredReviewsForWorkspace(ctx, tp.WorkspaceID, tp.Owner, tp.Repo, branch) +} + // TriggerPRSync performs an immediate PR status sync for a task. Single-repo // callers see the same single-PR contract as before. Multi-repo callers get // the primary repo's PR back; they should use TriggerPRSyncAll to refresh @@ -730,7 +895,7 @@ func (s *Service) triggerPRSyncAllPermanent(ctx context.Context, taskID string) return existing, false, nil } prs, syncErr := s.runBatchedOrPerWatchSync(ctx, taskID, watches) - return prs, s.areAllWatchesPermanentlyMissing(watches), syncErr + return prs, s.areAllWatchesPermanentlyMissing(ctx, watches), syncErr } // runBatchedOrPerWatchSync is the shared "try batched, fall back to @@ -738,7 +903,8 @@ func (s *Service) triggerPRSyncAllPermanent(ctx context.Context, taskID string) // permanent-flag computation can wrap the result without duplicating // the batched / fallback branches inline. func (s *Service) runBatchedOrPerWatchSync(ctx context.Context, taskID string, watches []*PRWatch) ([]*TaskPR, error) { - if _, batchErr := s.SyncWatchesBatched(ctx, watches); batchErr != nil { + workspaceID := watches[0].WorkspaceID + if _, batchErr := s.SyncWorkspaceWatchesBatched(ctx, workspaceID, watches); batchErr != nil { s.logger.Debug("batched PR sync failed; falling back to per-watch", zap.String("task_id", taskID), zap.Error(batchErr)) return s.triggerPRSyncAllPerWatch(ctx, taskID, watches) @@ -754,12 +920,13 @@ func (s *Service) runBatchedOrPerWatchSync(ctx context.Context, taskID string, w // retrying when a task points only at deleted/inaccessible repositories. // Returns false when the input is empty so an empty task doesn't get // classified as permanently missing. -func (s *Service) areAllWatchesPermanentlyMissing(watches []*PRWatch) bool { +func (s *Service) areAllWatchesPermanentlyMissing(ctx context.Context, watches []*PRWatch) bool { if len(watches) == 0 { return false } for _, w := range watches { - if !s.isRepoCachedAsMissing(w.Owner, w.Repo) { + resolved, err := s.resolveAutomationClient(ctx, w.WorkspaceID, w.Owner, w.Repo) + if err != nil || !s.isRepoCachedAsMissingForScope(resolved.CacheScope, w.Owner, w.Repo) { return false } } @@ -830,8 +997,12 @@ func (e *PartialPRSyncError) Unwrap() error { } func (s *Service) triggerPRDetection(ctx context.Context, watch *PRWatch, taskID string) (*TaskPR, error) { - if s.client == nil { - return nil, nil + if watch == nil || strings.TrimSpace(watch.WorkspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + resolved, err := s.resolveAutomationClient(ctx, watch.WorkspaceID, watch.Owner, watch.Repo) + if err != nil { + return nil, err } // Coalesce concurrent detection probes for the same watch. Without this the // freshness check below is racy: parallel callers (e.g. the 5s frontend @@ -840,9 +1011,9 @@ func (s *Service) triggerPRDetection(ctx context.Context, watch *PRWatch, taskID // Keyed distinctly from triggerPRStatusSync's owner/repo/number key so the // two never collide. singleflight only blocks same-key calls, so the nested // triggerPRStatusSync (different key) below cannot deadlock. - key := "pr-detect:" + watch.ID + key := scopedCacheKey(resolved.CacheScope, "pr-detect:"+watch.ID) v, err, _ := s.syncGroup.Do(key, func() (interface{}, error) { - return s.detectPRForWatchOnce(ctx, watch, taskID) + return s.detectPRForWatchOnce(ctx, resolved, watch, taskID) }) if err != nil { return nil, err @@ -856,12 +1027,14 @@ func (s *Service) triggerPRDetection(ctx context.Context, watch *PRWatch, taskID // detectPRForWatchOnce performs a single branch-detection probe for a // searching (pr_number=0) watch. It runs inside triggerPRDetection's // singleflight so only one probe per watch is in flight at a time. -func (s *Service) detectPRForWatchOnce(ctx context.Context, watch *PRWatch, taskID string) (*TaskPR, error) { +func (s *Service) detectPRForWatchOnce( + ctx context.Context, resolved *resolvedServiceClient, watch *PRWatch, taskID string, +) (*TaskPR, error) { // Short-circuit when this repo is already in the 10-min negative // cache. Without this, an unresolvable repo gets a fresh per-watch // probe every 5s (the frontend retry cadence) until the freshness // timestamp below stamps in — multiplied by every watch the task has. - if s.isRepoCachedAsMissing(watch.Owner, watch.Repo) { + if s.isRepoCachedAsMissingForScope(resolved.CacheScope, watch.Owner, watch.Repo) { return nil, ErrRepoNotResolvable } // Throttle branch-detection probes. A watch still searching for its PR @@ -879,9 +1052,9 @@ func (s *Service) detectPRForWatchOnce(ctx context.Context, watch *PRWatch, task // fires WHILE FindPRByBranch is running wins over our post-fetch // classification — see Service.markRepoAsMissing for the rationale. repoErrGen := s.repoErrorGenSnapshot() - pr, err := s.client.FindPRByBranch(ctx, watch.Owner, watch.Repo, watch.Branch) + pr, err := resolved.Client.FindPRByBranch(ctx, watch.Owner, watch.Repo, watch.Branch) if err != nil && isRepoNotResolvableErr(err) { - s.markRepoAsMissing(watch.Owner, watch.Repo, repoErrGen) + s.markRepoAsMissingForScope(resolved.CacheScope, watch.Owner, watch.Repo, repoErrGen) // Wrap so wsSyncTaskPR can errors.Is(err, ErrRepoNotResolvable) // to flag the WS response as permanent without re-running the // classifier on the raw upstream error string. @@ -912,7 +1085,7 @@ func (s *Service) detectPRForWatchOnce(ctx context.Context, watch *PRWatch, task zap.String("watch_id", watch.ID), zap.Int("pr_number", pr.Number), zap.Error(err)) return nil, fmt.Errorf("update PR watch: %w", err) } - if _, assocErr := s.AssociatePRWithTask(ctx, taskID, watch.RepositoryID, pr); assocErr != nil { + if _, assocErr := s.AssociatePRWithTaskForWorkspace(ctx, watch.WorkspaceID, taskID, watch.RepositoryID, pr); assocErr != nil { s.logger.Error("failed to associate PR with task during sync", zap.String("task_id", taskID), zap.Int("pr_number", pr.Number), zap.Error(assocErr)) return nil, fmt.Errorf("associate PR: %w", assocErr) @@ -923,6 +1096,13 @@ func (s *Service) detectPRForWatchOnce(ctx context.Context, watch *PRWatch, task } func (s *Service) triggerPRStatusSync(ctx context.Context, watch *PRWatch, taskID string) (*TaskPR, error) { + if watch == nil || strings.TrimSpace(watch.WorkspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + resolved, err := s.resolveAutomationClient(ctx, watch.WorkspaceID, watch.Owner, watch.Repo) + if err != nil { + return nil, err + } // Freshness check: skip GitHub API if this exact PR row was recently // synced. Multi-branch tasks can have multiple PRs in the same repo, so a // repo-only lookup would let a fresh sibling suppress this PR's sync. @@ -950,22 +1130,22 @@ func (s *Service) triggerPRStatusSync(ctx context.Context, watch *PRWatch, taskI // Short-circuit on negative cache so a 5s frontend retry against a // dead repo doesn't burn the gh throttle. Eviction happens on watch // (re)create paths, so a freshly linked repo is probed immediately. - if s.isRepoCachedAsMissing(watch.Owner, watch.Repo) { + if s.isRepoCachedAsMissingForScope(resolved.CacheScope, watch.Owner, watch.Repo) { return nil, ErrRepoNotResolvable } // Coalesce concurrent syncs for the same PR - key := fmt.Sprintf("%s/%s/%d", watch.Owner, watch.Repo, watch.PRNumber) + key := scopedCacheKey(resolved.CacheScope, fmt.Sprintf("%s/%s/%d", watch.Owner, watch.Repo, watch.PRNumber)) v, err, _ := s.syncGroup.Do(key, func() (interface{}, error) { bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Snapshot cache generation BEFORE the per-watch probe so a // concurrent eviction wins; see Service.markRepoAsMissing. repoErrGen := s.repoErrorGenSnapshot() - status, _, checkErr := s.CheckPRWatch(bgCtx, watch) + status, _, checkErr := s.checkPRWatchWithClient(bgCtx, resolved.Client, watch) if checkErr != nil { if isRepoNotResolvableErr(checkErr) { - s.markRepoAsMissing(watch.Owner, watch.Repo, repoErrGen) + s.markRepoAsMissingForScope(resolved.CacheScope, watch.Owner, watch.Repo, repoErrGen) return nil, fmt.Errorf("%w: %w", ErrRepoNotResolvable, checkErr) } return nil, checkErr diff --git a/apps/backend/internal/github/service_pr_watch_batched.go b/apps/backend/internal/github/service_pr_watch_batched.go index a802ba34cd..4518ac7244 100644 --- a/apps/backend/internal/github/service_pr_watch_batched.go +++ b/apps/backend/internal/github/service_pr_watch_batched.go @@ -36,15 +36,44 @@ type PRWatchSyncResult struct { // TriggerPRSyncAll / ListWorkspaceTaskPRs background refresh share, so a // 40-watch workspace fans out to ~2 gh subprocess calls instead of 40. func (s *Service) SyncWatchesBatched(ctx context.Context, watches []*PRWatch) ([]PRWatchSyncResult, error) { + return s.syncWatchesBatchedWithClient(ctx, s.client, "legacy", watches) +} + +// SyncWorkspaceWatchesBatched resolves one automation credential and rejects +// mixed or missing workspace ownership before making a provider call. +func (s *Service) SyncWorkspaceWatchesBatched( + ctx context.Context, workspaceID string, watches []*PRWatch, +) ([]PRWatchSyncResult, error) { if len(watches) == 0 { return nil, nil } - exec, err := graphQLExecutorFor(s.client) + if strings.TrimSpace(workspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + for _, watch := range watches { + if watch == nil || watch.WorkspaceID != workspaceID { + return nil, ErrGitHubWorkspaceRequired + } + } + resolved, err := s.resolveAutomationClient(ctx, workspaceID, "", "") + if err != nil { + return nil, err + } + return s.syncWatchesBatchedWithClient(ctx, resolved.Client, resolved.CacheScope, watches) +} + +func (s *Service) syncWatchesBatchedWithClient( + ctx context.Context, client Client, cacheScope string, watches []*PRWatch, +) ([]PRWatchSyncResult, error) { + if len(watches) == 0 { + return nil, nil + } + exec, err := graphQLExecutorFor(client) if err != nil { return nil, err } numbered, searching := splitPRWatches(watches) - statusByKey, err := s.fetchBatchedWatchStatuses(ctx, exec, numbered, searching) + statusByKey, err := s.fetchBatchedWatchStatuses(ctx, exec, cacheScope, numbered, searching) if err != nil { return nil, err } @@ -52,10 +81,10 @@ func (s *Service) SyncWatchesBatched(ctx context.Context, watches []*PRWatch) ([ results := make([]PRWatchSyncResult, 0, len(watches)) now := time.Now().UTC() for _, w := range numbered { - results = append(results, s.applyBatchedNumberedWatch(ctx, w, statusByKey, now)) + results = append(results, s.applyBatchedNumberedWatch(ctx, cacheScope, w, statusByKey, now)) } for _, w := range searching { - results = append(results, s.applyBatchedSearchingWatch(ctx, w, statusByKey, now)) + results = append(results, s.applyBatchedSearchingWatch(ctx, cacheScope, w, statusByKey, now)) } return results, nil } @@ -82,9 +111,9 @@ func (s *Service) SyncWatchesBatched(ctx context.Context, watches []*PRWatch) ([ // GetPRFeedback / GetPRStatus. The leader's deadline is preserved so // the fetch can't outlive the request budget. func (s *Service) fetchBatchedWatchStatuses( - ctx context.Context, exec GraphQLExecutor, numbered, searching []*PRWatch, + ctx context.Context, exec GraphQLExecutor, cacheScope string, numbered, searching []*PRWatch, ) (map[string]*PRStatus, error) { - key := batchedFetchSingleflightKey(numbered, searching) + key := scopedCacheKey(cacheScope, batchedFetchSingleflightKey(numbered, searching)) fetchCtx, cancelFetch := derivedFetchContext(ctx) defer cancelFetch() v, err, _ := s.syncGroup.Do(key, func() (interface{}, error) { @@ -94,10 +123,10 @@ func (s *Service) fetchBatchedWatchStatuses( // markRepoAsMissing writes — see Service.markRepoAsMissing. repoErrGen := s.repoErrorGenSnapshot() combined := make(map[string]*PRStatus, len(numbered)+len(searching)) - if err := s.fetchBatchedPRStatuses(fetchCtx, exec, numbered, combined, repoErrGen); err != nil { + if err := s.fetchBatchedPRStatuses(fetchCtx, exec, cacheScope, numbered, combined, repoErrGen); err != nil { return nil, err } - if err := s.fetchBatchedBranchStatuses(fetchCtx, exec, searching, combined, repoErrGen); err != nil { + if err := s.fetchBatchedBranchStatuses(fetchCtx, exec, cacheScope, searching, combined, repoErrGen); err != nil { return nil, err } return combined, nil @@ -140,12 +169,12 @@ func batchedFetchSingleflightKey(numbered, searching []*PRWatch) string { // 10 minutes; partial results for the repos that did resolve are merged // into `combined`. func (s *Service) fetchBatchedPRStatuses( - ctx context.Context, exec GraphQLExecutor, numbered []*PRWatch, combined map[string]*PRStatus, + ctx context.Context, exec GraphQLExecutor, cacheScope string, numbered []*PRWatch, combined map[string]*PRStatus, repoErrGen uint64, ) error { refs := make([]graphQLPRRef, 0, len(numbered)) for _, w := range numbered { - if s.isRepoCachedAsMissing(w.Owner, w.Repo) { + if s.isRepoCachedAsMissingForScope(cacheScope, w.Owner, w.Repo) { continue } refs = append(refs, graphQLPRRef{Owner: w.Owner, Repo: w.Repo, Number: w.PRNumber}) @@ -154,7 +183,7 @@ func (s *Service) fetchBatchedPRStatuses( return nil } out, err := runBatchedPRQuery(ctx, exec, refs) - out, err = s.absorbMissingReposErr(out, err, repoErrGen) + out, err = s.absorbMissingReposErr(out, err, cacheScope, repoErrGen) if err != nil { return fmt.Errorf("batched PR query: %w", err) } @@ -167,12 +196,12 @@ func (s *Service) fetchBatchedPRStatuses( // fetchBatchedBranchStatuses runs the branch-keyed batch with the same // negative-cache filter and missing-repo absorption as the numbered path. func (s *Service) fetchBatchedBranchStatuses( - ctx context.Context, exec GraphQLExecutor, searching []*PRWatch, combined map[string]*PRStatus, + ctx context.Context, exec GraphQLExecutor, cacheScope string, searching []*PRWatch, combined map[string]*PRStatus, repoErrGen uint64, ) error { refs := make([]graphQLBranchRef, 0, len(searching)) for _, w := range searching { - if s.isRepoCachedAsMissing(w.Owner, w.Repo) { + if s.isRepoCachedAsMissingForScope(cacheScope, w.Owner, w.Repo) { continue } refs = append(refs, graphQLBranchRef{Owner: w.Owner, Repo: w.Repo, Branch: w.Branch}) @@ -181,7 +210,7 @@ func (s *Service) fetchBatchedBranchStatuses( return nil } out, err := runBatchedBranchQuery(ctx, exec, refs) - out, err = s.absorbMissingReposErr(out, err, repoErrGen) + out, err = s.absorbMissingReposErr(out, err, cacheScope, repoErrGen) if err != nil { return fmt.Errorf("batched branch query: %w", err) } @@ -199,7 +228,9 @@ func (s *Service) fetchBatchedBranchStatuses( // present, the inner error is returned unchanged so the caller falls // back to per-watch checks. `repoErrGen` is the negative-cache generation // snapshot taken BEFORE the fetch so a concurrent eviction wins. -func (s *Service) absorbMissingReposErr(out map[string]*PRStatus, err error, repoErrGen uint64) (map[string]*PRStatus, error) { +func (s *Service) absorbMissingReposErr( + out map[string]*PRStatus, err error, cacheScope string, repoErrGen uint64, +) (map[string]*PRStatus, error) { if err == nil { return out, nil } @@ -208,7 +239,7 @@ func (s *Service) absorbMissingReposErr(out map[string]*PRStatus, err error, rep return nil, err } for _, r := range missingErr.Repos { - s.markRepoAsMissing(r.Owner, r.Repo, repoErrGen) + s.markRepoAsMissingForScope(cacheScope, r.Owner, r.Repo, repoErrGen) s.logger.Debug("repo not resolvable; negative-cached", zap.String("owner", r.Owner), zap.String("repo", r.Repo)) } @@ -221,13 +252,13 @@ func (s *Service) absorbMissingReposErr(out map[string]*PRStatus, err error, rep // applyBatchedNumberedWatch mirrors Poller.applyPRStatus on the service // side so on-demand callers reuse the same DB-write sequence. func (s *Service) applyBatchedNumberedWatch( - ctx context.Context, w *PRWatch, statusByKey map[string]*PRStatus, now time.Time, + ctx context.Context, cacheScope string, w *PRWatch, statusByKey map[string]*PRStatus, now time.Time, ) PRWatchSyncResult { // Repo is in the 10-min negative cache — the fetch path didn't include // it in the GraphQL batch, so the apply path can't probe upstream either. // Bump last_checked_at to suppress retry storms and surface SyncFailed // so the WS handler can flag the result as permanent for the frontend. - if s.isRepoCachedAsMissing(w.Owner, w.Repo) { + if s.isRepoCachedAsMissingForScope(cacheScope, w.Owner, w.Repo) { _ = s.store.UpdatePRWatchTimestamps(ctx, w.ID, now, w.LastCommentAt, "", "") return PRWatchSyncResult{Watch: w, SyncFailed: true} } @@ -257,7 +288,7 @@ func (s *Service) applyBatchedNumberedWatch( zap.Int("pr_number", w.PRNumber), zap.Error(err)) return PRWatchSyncResult{Watch: w, Status: status, Found: true, SyncFailed: true} } else if existing == nil && status.PR != nil { - if _, assocErr := s.AssociatePRWithTask(ctx, w.TaskID, w.RepositoryID, status.PR); assocErr != nil { + if _, assocErr := s.AssociatePRWithTaskForWorkspace(ctx, w.WorkspaceID, w.TaskID, w.RepositoryID, status.PR); assocErr != nil { s.logger.Error("failed to associate numbered PR with task", zap.String("task_id", w.TaskID), zap.Int("pr_number", w.PRNumber), zap.Error(assocErr)) return PRWatchSyncResult{Watch: w, Status: status, Found: true, SyncFailed: true} @@ -284,13 +315,13 @@ func (s *Service) applyBatchedNumberedWatch( // side. A searching watch (pr_number=0) is promoted to a known PR when the // branch lookup returns one; otherwise we just bump last_checked_at. func (s *Service) applyBatchedSearchingWatch( - ctx context.Context, w *PRWatch, statusByKey map[string]*PRStatus, now time.Time, + ctx context.Context, cacheScope string, w *PRWatch, statusByKey map[string]*PRStatus, now time.Time, ) PRWatchSyncResult { // Timestamps get bumped on both the "no PR found" and "PR detected" // paths, so hoist the single call above the branch to make that // invariant obvious. _ = s.store.UpdatePRWatchTimestamps(ctx, w.ID, now, nil, "", "") - if s.isRepoCachedAsMissing(w.Owner, w.Repo) { + if s.isRepoCachedAsMissingForScope(cacheScope, w.Owner, w.Repo) { return PRWatchSyncResult{Watch: w, SyncFailed: true} } status, ok := statusByKey[graphqlBranchKey(w.Owner, w.Repo, w.Branch)] @@ -302,7 +333,7 @@ func (s *Service) applyBatchedSearchingWatch( zap.String("watch_id", w.ID), zap.Int("pr_number", status.PR.Number), zap.Error(err)) return PRWatchSyncResult{Watch: w, Status: status, Found: true} } - if _, err := s.AssociatePRWithTask(ctx, w.TaskID, w.RepositoryID, status.PR); err != nil { + if _, err := s.AssociatePRWithTaskForWorkspace(ctx, w.WorkspaceID, w.TaskID, w.RepositoryID, status.PR); err != nil { s.logger.Error("failed to associate detected PR with task", zap.String("task_id", w.TaskID), zap.Int("pr_number", status.PR.Number), zap.Error(err)) return PRWatchSyncResult{Watch: w, Status: status, Found: true} diff --git a/apps/backend/internal/github/service_pr_watch_multi_branch_test.go b/apps/backend/internal/github/service_pr_watch_multi_branch_test.go index 8976c3c56a..7e8a4231d9 100644 --- a/apps/backend/internal/github/service_pr_watch_multi_branch_test.go +++ b/apps/backend/internal/github/service_pr_watch_multi_branch_test.go @@ -18,7 +18,7 @@ func TestCreatePRWatch_AllowsMultipleBranchesPerRepo(t *testing.T) { seedTask(t, store, "task-1", false) - w1, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/primary") + w1, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/primary") if err != nil { t.Fatalf("CreatePRWatch primary: %v", err) } @@ -26,7 +26,7 @@ func TestCreatePRWatch_AllowsMultipleBranchesPerRepo(t *testing.T) { t.Fatal("primary watch must not be nil") } - w2, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/branch-2") + w2, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/branch-2") if err != nil { t.Fatalf("CreatePRWatch branch-2: %v", err) } @@ -56,11 +56,11 @@ func TestCreatePRWatch_IdempotentPerBranch(t *testing.T) { seedTask(t, store, "task-1", false) - first, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/x") + first, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/x") if err != nil { t.Fatalf("first CreatePRWatch: %v", err) } - second, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/x") + second, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/x") if err != nil { t.Fatalf("second CreatePRWatch: %v", err) } @@ -86,11 +86,11 @@ func TestGetPRWatchBySessionRepoAndBranch_FindsRightRow(t *testing.T) { ctx := context.Background() seedTask(t, svc.store, "task-1", false) - primary, err := svc.CreatePRWatch(ctx, "s1", "task-1", "repo-1", "owner", "repo", 1217, "feature/primary") + primary, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "s1", "task-1", "repo-1", "owner", "repo", 1217, "feature/primary") if err != nil { t.Fatalf("primary: %v", err) } - secondary, err := svc.CreatePRWatch(ctx, "s1", "task-1", "repo-1", "owner", "repo", 0, "feature/secondary") + secondary, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "s1", "task-1", "repo-1", "owner", "repo", 0, "feature/secondary") if err != nil { t.Fatalf("secondary: %v", err) } @@ -120,7 +120,7 @@ func TestUpdatePRWatchBranchIfSearching_NoCollision_UpdatesBranch(t *testing.T) ctx := context.Background() seedTask(t, store, "task-1", false) - w, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/old") + w, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/old") if err != nil { t.Fatalf("create watch: %v", err) } @@ -147,11 +147,11 @@ func TestUpdatePRWatchBranchIfSearching_CollidesWithSibling_DropsSource(t *testi ctx := context.Background() seedTask(t, store, "task-1", false) - source, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/A") + source, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/A") if err != nil { t.Fatalf("create source watch: %v", err) } - sibling, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/B") + sibling, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/B") if err != nil { t.Fatalf("create sibling watch: %v", err) } @@ -185,11 +185,11 @@ func TestUpdatePRWatchBranchIfSearching_CollidesWithSiblingHasPR_DropsSource(t * ctx := context.Background() seedTask(t, store, "task-1", false) - source, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/A") + source, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/A") if err != nil { t.Fatalf("create source watch: %v", err) } - sibling, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/B") + sibling, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/B") if err != nil { t.Fatalf("create sibling watch: %v", err) } @@ -227,7 +227,7 @@ func TestUpdatePRWatchBranchIfSearching_PRAlreadyFound_NoOp(t *testing.T) { ctx := context.Background() seedTask(t, store, "task-1", false) - w, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 42, "feature/found") + w, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 42, "feature/found") if err != nil { t.Fatalf("create watch: %v", err) } @@ -284,7 +284,7 @@ func TestTriggerPRStatusSync_AssociatesExactPRWhenSiblingIsFresh(t *testing.T) { t.Fatalf("seed merged sibling: %v", err) } - watch, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 1299, "feature/second") + watch, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 1299, "feature/second") if err != nil { t.Fatalf("CreatePRWatch: %v", err) } @@ -344,7 +344,7 @@ func TestApplyBatchedNumberedWatch_AssociatesExactPRWhenSiblingExists(t *testing t.Fatalf("seed merged sibling: %v", err) } - watch, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 1299, "feature/second") + watch, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 1299, "feature/second") if err != nil { t.Fatalf("CreatePRWatch: %v", err) } @@ -364,7 +364,7 @@ func TestApplyBatchedNumberedWatch_AssociatesExactPRWhenSiblingExists(t *testing ChecksState: "pending", } - result := svc.applyBatchedNumberedWatch(ctx, watch, map[string]*PRStatus{ + result := svc.applyBatchedNumberedWatch(ctx, "legacy", watch, map[string]*PRStatus{ prStatusCacheKey("owner", "repo", 1299): status, }, now) if result.SyncFailed { @@ -386,12 +386,12 @@ func TestApplyBatchedNumberedWatch_MissingPRDataDoesNotPanic(t *testing.T) { seedTask(t, store, "task-1", false) now := time.Now().UTC() - watch, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 1299, "feature/second") + watch, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 1299, "feature/second") if err != nil { t.Fatalf("CreatePRWatch: %v", err) } - result := svc.applyBatchedNumberedWatch(ctx, watch, map[string]*PRStatus{ + result := svc.applyBatchedNumberedWatch(ctx, "legacy", watch, map[string]*PRStatus{ prStatusCacheKey("owner", "repo", 1299): &PRStatus{ChecksState: "pending"}, }, now) if !result.SyncFailed { diff --git a/apps/backend/internal/github/service_repo_contents.go b/apps/backend/internal/github/service_repo_contents.go new file mode 100644 index 0000000000..56af7c81dd --- /dev/null +++ b/apps/backend/internal/github/service_repo_contents.go @@ -0,0 +1,29 @@ +package github + +import "context" + +func (s *Service) ListRepoDirectoryForWorkspace( + ctx context.Context, workspaceID, owner, repo, path, ref string, +) ([]RepoContentEntry, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + resolved, err := s.resolveAutomationClient(ctx, workspaceID, owner, repo) + if err != nil { + return nil, err + } + return resolved.Client.ListRepoDirectory(ctx, owner, repo, path, ref) +} + +func (s *Service) GetRepoFileContentForWorkspace( + ctx context.Context, workspaceID, owner, repo, path, ref string, +) ([]byte, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + resolved, err := s.resolveAutomationClient(ctx, workspaceID, owner, repo) + if err != nil { + return nil, err + } + return resolved.Client.GetRepoFileContent(ctx, owner, repo, path, ref) +} diff --git a/apps/backend/internal/github/service_repo_negative_cache_test.go b/apps/backend/internal/github/service_repo_negative_cache_test.go index 1222c1dc3a..4170fd80b4 100644 --- a/apps/backend/internal/github/service_repo_negative_cache_test.go +++ b/apps/backend/internal/github/service_repo_negative_cache_test.go @@ -24,16 +24,17 @@ func TestSyncWatchesBatched_NegativeCache_ShortCircuits(t *testing.T) { ctx := context.Background() w := &PRWatch{SessionID: "s1", TaskID: "t1", Owner: "Dead", Repo: "Repo", PRNumber: 7, Branch: "br"} - if err := store.CreatePRWatch(ctx, w); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(w)); err != nil { t.Fatalf("create watch: %v", err) } // CreatePRWatch evicts the negative cache, so prime it AFTER the // watch is in place. - svc.markRepoAsMissing(w.Owner, w.Repo, svc.repoErrorGenSnapshot()) + scope := testAutomationScope(t, svc, testWorkspaceID) + svc.markRepoAsMissingForScope(scope, w.Owner, w.Repo, svc.repoErrorGenSnapshot()) // No canned responses — if anything reaches gh the test fails fast // with "no canned PR response" / "no canned branch response". - results, err := svc.SyncWatchesBatched(ctx, []*PRWatch{w}) + results, err := svc.SyncWorkspaceWatchesBatched(ctx, testWorkspaceID, []*PRWatch{w}) if err != nil { t.Fatalf("SyncWatchesBatched: %v", err) } @@ -58,7 +59,7 @@ func TestSyncWatchesBatched_LearnsAndCachesMissingRepo(t *testing.T) { ctx := context.Background() w := &PRWatch{SessionID: "s1", TaskID: "t1", Owner: "Dead", Repo: "Repo", PRNumber: 7, Branch: "br"} - if err := store.CreatePRWatch(ctx, w); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(w)); err != nil { t.Fatalf("create watch: %v", err) } @@ -70,15 +71,15 @@ func TestSyncWatchesBatched_LearnsAndCachesMissingRepo(t *testing.T) { "errors": [{"message": "Could not resolve to a Repository with the name 'Dead/Repo'.", "type": "NOT_FOUND", "path": ["repo0"]}] }`} - if _, err := svc.SyncWatchesBatched(ctx, []*PRWatch{w}); err != nil { + if _, err := svc.SyncWorkspaceWatchesBatched(ctx, testWorkspaceID, []*PRWatch{w}); err != nil { t.Fatalf("first SyncWatchesBatched: %v", err) } - if !svc.isRepoCachedAsMissing(w.Owner, w.Repo) { + if !svc.isRepoCachedAsMissingForScope(testAutomationScope(t, svc, testWorkspaceID), w.Owner, w.Repo) { t.Fatalf("expected repo to be negative-cached after first sync; cache=%v", svc.repoErrorCache.entries) } // Second call must NOT hit gh — the cache short-circuits everything. - if _, err := svc.SyncWatchesBatched(ctx, []*PRWatch{w}); err != nil { + if _, err := svc.SyncWorkspaceWatchesBatched(ctx, testWorkspaceID, []*PRWatch{w}); err != nil { t.Fatalf("second SyncWatchesBatched: %v", err) } if got := len(gh.prQueries); got != 1 { @@ -113,7 +114,7 @@ func TestSyncWatchesBatched_NegativeCache_CoalescesConcurrentCalls(t *testing.T) _, svc, gh, store := setupBatchedPollerTest(t) w := &PRWatch{SessionID: "s1", TaskID: "t1", Owner: "Dead", Repo: "Repo", PRNumber: 7, Branch: "br"} - if err := store.CreatePRWatch(context.Background(), w); err != nil { + if err := store.CreatePRWatch(context.Background(), withTestWorkspace(w)); err != nil { t.Fatalf("create watch: %v", err) } @@ -122,10 +123,12 @@ func TestSyncWatchesBatched_NegativeCache_CoalescesConcurrentCalls(t *testing.T) // ExecuteGraphQL. (Coalescing-via-singleflight inside the cache is // covered by lower-level cache tests; here we just need to confirm // SyncWatchesBatched honors the cache under concurrency.) - svc.markRepoAsMissing(w.Owner, w.Repo, svc.repoErrorGenSnapshot()) + scope := testAutomationScope(t, svc, testWorkspaceID) + svc.markRepoAsMissingForScope(scope, w.Owner, w.Repo, svc.repoErrorGenSnapshot()) blocker := &blockingExec{graphQLMockClient: gh, release: make(chan struct{})} svc.client = blocker + configureTestWorkspaceAuth(t, svc, blocker, testWorkspaceID, "ws-1", "ws1") close(blocker.release) // never block — if any call reaches gh the test will catch it via blocker.calls const n = 16 @@ -134,7 +137,9 @@ func TestSyncWatchesBatched_NegativeCache_CoalescesConcurrentCalls(t *testing.T) for i := 0; i < n; i++ { go func() { defer wg.Done() - if _, err := svc.SyncWatchesBatched(context.Background(), []*PRWatch{w}); err != nil { + if _, err := svc.SyncWorkspaceWatchesBatched( + context.Background(), testWorkspaceID, []*PRWatch{w}, + ); err != nil { t.Errorf("concurrent SyncWatchesBatched: %v", err) } }() @@ -159,7 +164,7 @@ func TestSyncWatchesBatched_FirstMissCoalesces(t *testing.T) { _, svc, gh, store := setupBatchedPollerTest(t) w := &PRWatch{SessionID: "s1", TaskID: "t1", Owner: "Dead", Repo: "Repo", PRNumber: 7, Branch: "br"} - if err := store.CreatePRWatch(context.Background(), w); err != nil { + if err := store.CreatePRWatch(context.Background(), withTestWorkspace(w)); err != nil { t.Fatalf("create watch: %v", err) } @@ -193,7 +198,9 @@ func TestSyncWatchesBatched_FirstMissCoalesces(t *testing.T) { go func() { defer wg.Done() joined.Add(1) - if _, err := svc.SyncWatchesBatched(context.Background(), []*PRWatch{w}); err != nil { + if _, err := svc.SyncWorkspaceWatchesBatched( + context.Background(), testWorkspaceID, []*PRWatch{w}, + ); err != nil { errs <- err } }() @@ -217,7 +224,7 @@ func TestSyncWatchesBatched_FirstMissCoalesces(t *testing.T) { if got := len(gh.prQueries); got != 1 { t.Errorf("expected 1 upstream PR call when concurrent first-time misses coalesce, got %d", got) } - if !svc.isRepoCachedAsMissing(w.Owner, w.Repo) { + if !svc.isRepoCachedAsMissingForScope(testAutomationScope(t, svc, testWorkspaceID), w.Owner, w.Repo) { t.Errorf("expected repo to be negative-cached after the coalesced fetch") } } @@ -263,7 +270,9 @@ func TestService_EvictRepoNegative_OnWatchCreate(t *testing.T) { // CreatePRWatch must evict regardless of input casing, because the // cache key is case-insensitive. w := &PRWatch{SessionID: "s1", TaskID: "t1", Owner: "dead", Repo: "repo", PRNumber: 1, Branch: "main"} - if _, err := svc.CreatePRWatch(ctx, w.SessionID, w.TaskID, "", w.Owner, w.Repo, w.PRNumber, w.Branch); err != nil { + if _, err := svc.CreatePRWatchForWorkspace( + ctx, testWorkspaceID, w.SessionID, w.TaskID, "", w.Owner, w.Repo, w.PRNumber, w.Branch, + ); err != nil { t.Fatalf("CreatePRWatch: %v", err) } _ = store @@ -283,10 +292,11 @@ func TestService_TriggerPRSyncAllPermanent_FlagsDeadRepos(t *testing.T) { ctx := context.Background() w1 := &PRWatch{SessionID: "s1", TaskID: "t1", Owner: "Dead", Repo: "Repo", PRNumber: 0, Branch: "x"} - if err := store.CreatePRWatch(ctx, w1); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(w1)); err != nil { t.Fatalf("create w1: %v", err) } - svc.markRepoAsMissing(w1.Owner, w1.Repo, svc.repoErrorGenSnapshot()) + scope := testAutomationScope(t, svc, testWorkspaceID) + svc.markRepoAsMissingForScope(scope, w1.Owner, w1.Repo, svc.repoErrorGenSnapshot()) // No canned responses needed — the cache short-circuits both watches. _, permanent, err := svc.TriggerPRSyncAllPermanent(ctx, "t1") @@ -300,7 +310,7 @@ func TestService_TriggerPRSyncAllPermanent_FlagsDeadRepos(t *testing.T) { // Add a second watch on a live repo; permanent must flip to false // even though w1 is still cached-missing. w2 := &PRWatch{SessionID: "s2", TaskID: "t1", Owner: "Live", Repo: "Repo", PRNumber: 0, Branch: "y"} - if err := store.CreatePRWatch(ctx, w2); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(w2)); err != nil { t.Fatalf("create w2: %v", err) } gh.branchResponses = []string{`{"data":{"b0":{"pullRequests":{"nodes":[]}}}}`} @@ -329,7 +339,7 @@ func TestWSSyncTaskPR_ReturnsPermanentEnvelopeWhenSyncErrors(t *testing.T) { ctx := context.Background() w := &PRWatch{SessionID: "s1", TaskID: "t1", Owner: "Dead", Repo: "Repo", PRNumber: 0, Branch: "x"} - if err := store.CreatePRWatch(ctx, w); err != nil { + if err := store.CreatePRWatch(ctx, withTestWorkspace(w)); err != nil { t.Fatalf("create watch: %v", err) } gh.branchErr = &batchedMissingReposErr{ diff --git a/apps/backend/internal/github/service_request_review_test.go b/apps/backend/internal/github/service_request_review_test.go index d0f5b1ef2f..8ae722d719 100644 --- a/apps/backend/internal/github/service_request_review_test.go +++ b/apps/backend/internal/github/service_request_review_test.go @@ -14,11 +14,47 @@ type cacheCountingReviewRequestClient struct { err error } +type reviewRequestInstallationProvider struct { + client Client + capabilities map[GitHubAppCapability]bool +} + +func (p reviewRequestInstallationProvider) ResolveInstallation( + context.Context, + *WorkspaceConnection, + ResolveCredentialRequest, +) (*ResolvedCredential, error) { + return &ResolvedCredential{ + Client: p.client, + Principal: AuthPrincipal{ + Kind: AuthPrincipalApp, Source: ConnectionSourceGitHubAppInstallation, + }, + Capabilities: p.capabilities, + }, nil +} + +func newAppReviewRequestService(t *testing.T, client Client, capabilities map[GitHubAppCapability]bool) *Service { + t.Helper() + installationID := int64(42) + service := newTestService(nil) + service.resolver = NewCredentialResolver(&fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{ + "workspace-a": { + WorkspaceID: "workspace-a", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, AppRegistrationID: "registration-a", + Status: ConnectionStatusActive, CredentialGeneration: 1, + }, + }}, nil) + service.resolver.SetInstallationProvider(reviewRequestInstallationProvider{ + client: client, capabilities: capabilities, + }) + return service +} + func TestService_RequestReviewers_InvalidatesOnlyAffectedInFlightPRCaches(t *testing.T) { client := &cacheCountingReviewRequestClient{MockClient: NewMockClient()} svc := newTestService(client) - key := prStatusCacheKey("o", "r", 1) - unrelatedKey := prStatusCacheKey("o", "r", 2) + key := scopedCacheKey("legacy", prStatusCacheKey("o", "r", 1)) + unrelatedKey := scopedCacheKey("legacy", prStatusCacheKey("o", "r", 2)) for name, cache := range map[string]*ttlCache{ "feedback": svc.prFeedbackCache, @@ -158,3 +194,70 @@ func TestService_RequestReviewers_KeepsCachesOnFailure(t *testing.T) { t.Fatalf("failed request recorded mock pending state: %#v", requests) } } + +func TestService_RequestReviewersForWorkspace_InvalidatesCorrespondingPersonalReadCaches(t *testing.T) { + client := &cacheCountingReviewRequestClient{MockClient: NewMockClient()} + client.AddRepos("test-user", []GitHubRepo{{Owner: "o", Name: "r", FullName: "o/r"}}) + service := newTestService(client) + configureTestWorkspaceAuth(t, service, client, "workspace-a") + ctx := context.Background() + + if _, err := service.GetPRFeedbackForWorkspace(ctx, "workspace-a", "user-a", "o", "r", 1); err != nil { + t.Fatalf("prime feedback cache: %v", err) + } + if _, err := service.GetPRStatusForWorkspace(ctx, "workspace-a", "user-a", "o", "r", 1); err != nil { + t.Fatalf("prime status cache: %v", err) + } + + principal, err := service.RequestReviewersForWorkspace( + ctx, "workspace-a", "user-a", "o", "r", 1, []string{"octocat"}, + ) + if err != nil { + t.Fatalf("RequestReviewersForWorkspace: %v", err) + } + if principal.WorkspaceID != "workspace-a" || principal.Kind != AuthPrincipalHuman { + t.Fatalf("principal = %+v", principal) + } + if requests := client.RequestedReviews(); len(requests) != 1 || requests[0].Owner != "o" { + t.Fatalf("review requests = %#v", requests) + } + + if _, err := service.GetPRFeedbackForWorkspace(ctx, "workspace-a", "user-a", "o", "r", 1); err != nil { + t.Fatalf("refetch feedback cache: %v", err) + } + if _, err := service.GetPRStatusForWorkspace(ctx, "workspace-a", "user-a", "o", "r", 1); err != nil { + t.Fatalf("refetch status cache: %v", err) + } + if client.feedbackCalls != 2 || client.statusCalls != 2 { + t.Fatalf("calls feedback/status = %d/%d, want 2/2", client.feedbackCalls, client.statusCalls) + } +} + +func TestService_RequestReviewersForWorkspace_RejectsMissingAppWriteCapability(t *testing.T) { + service := newAppReviewRequestService(t, &stubClient{}, nil) + _, err := service.RequestReviewersForWorkspace( + context.Background(), "workspace-a", "user-a", "o", "r", 1, []string{"octocat"}, + ) + if !errors.Is(err, ErrGitHubCapabilityDenied) { + t.Fatalf("RequestReviewersForWorkspace error = %v, want capability denied", err) + } +} + +func TestService_RequestReviewersForWorkspace_RejectsRepositoryOutsideWorkspaceScope(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-a") + if err := store.UpsertWorkspaceSettings(context.Background(), &WorkspaceSettings{ + WorkspaceID: "workspace-a", RepoScopeMode: RepoScopeModeRepos, + RepoScopeRepos: []RepoFilter{{Owner: "o", Name: "allowed"}}, + }); err != nil { + t.Fatalf("configure workspace repository scope: %v", err) + } + service := NewService(NewMockClient(), AuthMethodPAT, nil, store, nil, testLogger(t)) + configureTestWorkspaceAuth(t, service, NewMockClient(), "workspace-a") + _, err := service.RequestReviewersForWorkspace( + context.Background(), "workspace-a", "user-a", "o", "outside", 1, []string{"octocat"}, + ) + if !errors.Is(err, ErrRepoNotResolvable) { + t.Fatalf("RequestReviewersForWorkspace error = %v, want repository scope denial", err) + } +} diff --git a/apps/backend/internal/github/service_reviews.go b/apps/backend/internal/github/service_reviews.go index 5daa7a6596..c5db428563 100644 --- a/apps/backend/internal/github/service_reviews.go +++ b/apps/backend/internal/github/service_reviews.go @@ -22,6 +22,34 @@ import ( // CreateReviewWatch creates a new review watch and triggers an initial poll. func (s *Service) CreateReviewWatch(ctx context.Context, req *CreateReviewWatchRequest) (*ReviewWatch, error) { + return s.createReviewWatch(ctx, req, "") +} + +func (s *Service) CreateReviewWatchForUser( + ctx context.Context, userID string, req *CreateReviewWatchRequest, +) (*ReviewWatch, error) { + if req == nil || strings.TrimSpace(req.WorkspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + resolved, err := s.resolvePersonalReadClient(ctx, req.WorkspaceID, userID, "", "") + if err != nil { + return nil, err + } + if strings.TrimSpace(resolved.Principal.Login) == "" { + return nil, ErrGitHubPersonalRequired + } + return s.createReviewWatch(ctx, req, resolved.Principal.Login) +} + +func (s *Service) createReviewWatch( + ctx context.Context, req *CreateReviewWatchRequest, targetLogin string, +) (*ReviewWatch, error) { + if req == nil || strings.TrimSpace(req.WorkspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + if err := s.authorizeWorkspaceAccess(ctx, req.WorkspaceID); err != nil { + return nil, err + } if req.PollIntervalSeconds <= 0 { req.PollIntervalSeconds = defaultWatchPollIntervalSec } @@ -49,6 +77,7 @@ func (s *Service) CreateReviewWatch(ctx context.Context, req *CreateReviewWatchR Prompt: req.Prompt, ReviewScope: reviewScope, CustomQuery: req.CustomQuery, + TargetLogin: targetLogin, Enabled: true, PollIntervalSeconds: req.PollIntervalSeconds, CleanupPolicy: NormalizeCleanupPolicy(req.CleanupPolicy), @@ -80,11 +109,21 @@ func (s *Service) initialReviewCheck(ctx context.Context, watch *ReviewWatch) { // GetReviewWatch returns a review watch by ID. func (s *Service) GetReviewWatch(ctx context.Context, id string) (*ReviewWatch, error) { - return s.store.GetReviewWatch(ctx, id) + watch, err := s.store.GetReviewWatch(ctx, id) + if err != nil || watch == nil { + return watch, err + } + if err := s.authorizeWorkspaceAccess(ctx, watch.WorkspaceID); err != nil { + return nil, err + } + return watch, nil } // ListReviewWatches returns all review watches for a workspace. func (s *Service) ListReviewWatches(ctx context.Context, workspaceID string) ([]*ReviewWatch, error) { + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } return s.store.ListReviewWatches(ctx, workspaceID) } @@ -109,6 +148,9 @@ func (s *Service) UpdateReviewWatch(ctx context.Context, id string, req *UpdateR if rw == nil { return fmt.Errorf("review watch not found: %s", id) } + if err := s.authorizeWorkspaceAccess(ctx, rw.WorkspaceID); err != nil { + return err + } if req.WorkflowID != nil { rw.WorkflowID = *req.WorkflowID } @@ -158,6 +200,15 @@ func (s *Service) UpdateReviewWatch(ctx context.Context, id string, req *UpdateR // //nolint:nestif // straight-line list → for-loop → conditional delete; readable as-is func (s *Service) DeleteReviewWatch(ctx context.Context, id string) error { + watch, err := s.store.GetReviewWatch(ctx, id) + if err != nil { + return err + } + if watch != nil { + if err := s.authorizeWorkspaceAccess(ctx, watch.WorkspaceID); err != nil { + return err + } + } if s.taskDeleter != nil { prTasks, err := s.store.ListReviewPRTasksByWatch(ctx, id) if err != nil { @@ -189,6 +240,9 @@ func (s *Service) DeleteReviewWatch(ctx context.Context, id string) error { // test adds that the stale watch also matches. Best-effort per watch — a // single failure logs Warn and the sweep continues. Returns the count deleted. func (s *Service) DeleteReviewWatchesByWorkspace(ctx context.Context, workspaceID string) (int, error) { + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return 0, err + } watches, err := s.store.ListReviewWatches(ctx, workspaceID) if err != nil { return 0, fmt.Errorf("list review watches: %w", err) @@ -208,8 +262,26 @@ func (s *Service) DeleteReviewWatchesByWorkspace(ctx context.Context, workspaceI // CheckReviewWatch checks for new PRs needing review and returns ones not yet tracked. // If watch.Repos is empty, all repos are queried. Otherwise, each repo is queried individually. func (s *Service) CheckReviewWatch(ctx context.Context, watch *ReviewWatch) ([]*PR, error) { - if s.client == nil { - return nil, fmt.Errorf("github client not available") + if watch == nil || strings.TrimSpace(watch.WorkspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + connection, err := s.store.GetWorkspaceConnection(ctx, watch.WorkspaceID) + if err != nil { + return nil, fmt.Errorf("load review watch workspace connection: %w", err) + } + if connection != nil && connection.Source == ConnectionSourceGitHubAppInstallation && + strings.TrimSpace(watch.TargetLogin) == "" { + const cause = "Reconnect a personal GitHub identity to select the review target." + if disableErr := s.store.DisableReviewWatchWithError(ctx, watch.ID, cause); disableErr != nil { + return nil, disableErr + } + watch.Enabled = false + watch.LastError = cause + return nil, ErrGitHubPersonalRequired + } + resolved, err := s.resolveAutomationClient(ctx, watch.WorkspaceID, "", "") + if err != nil { + return nil, err } s.logger.Debug("checking review watch for pending PRs", @@ -230,7 +302,7 @@ func (s *Service) CheckReviewWatch(ctx context.Context, watch *ReviewWatch) ([]* if len(fetchWatch.Repos) == 0 { fetchWatch.Repos = workspaceScopeRepoFilters(settings) } - prs, err := s.fetchReviewPRs(ctx, &fetchWatch) + prs, err := s.fetchReviewPRs(ctx, resolved.Client, &fetchWatch) if err != nil { return nil, err } @@ -265,7 +337,7 @@ func (s *Service) CheckReviewWatch(ctx context.Context, watch *ReviewWatch) ([]* // Enrich new PRs with full details (branch info) from the PR API, // since the search API does not return head/base branch. - s.enrichPRDetails(ctx, newPRs) + s.enrichPRDetails(ctx, resolved.Client, newPRs) s.logger.Debug("review watch check complete", zap.String("watch_id", watch.ID), @@ -297,8 +369,9 @@ func (s *Service) TriggerReviewWatch(ctx context.Context, watch *ReviewWatch) ([ // fetchReviewPRs fetches PRs needing review based on the watch configuration. // When repo filters are set, they are always applied — even when a custom query is present // (the filter qualifier is appended to the query for each repo). -func (s *Service) fetchReviewPRs(ctx context.Context, watch *ReviewWatch) ([]*PR, error) { +func (s *Service) fetchReviewPRs(ctx context.Context, client Client, watch *ReviewWatch) ([]*PR, error) { hasRepos := len(watch.Repos) > 0 + customQuery := reviewWatchQuery(watch) s.logger.Debug("fetchReviewPRs: starting", zap.String("watch_id", watch.ID), @@ -309,24 +382,24 @@ func (s *Service) fetchReviewPRs(ctx context.Context, watch *ReviewWatch) ([]*PR // No repo filters: use query verbatim (custom or scope-based) if !hasRepos { - if watch.CustomQuery != "" { + if customQuery != "" { s.logger.Debug("fetchReviewPRs: using custom query (all repos)", - zap.String("query", watch.CustomQuery)) - return s.client.ListReviewRequestedPRs(ctx, "", "", watch.CustomQuery) + zap.String("query", customQuery)) + return client.ListReviewRequestedPRs(ctx, "", "", customQuery) } s.logger.Debug("fetchReviewPRs: using scope (all repos)", zap.String("scope", watch.ReviewScope)) - return s.client.ListReviewRequestedPRs(ctx, watch.ReviewScope, "", "") + return client.ListReviewRequestedPRs(ctx, watch.ReviewScope, "", "") } // Has repo filters: iterate repos, appending filter to customQuery or scope - prs := s.fetchReviewPRsWithFilter(ctx, watch) + prs := s.fetchReviewPRsWithFilter(ctx, client, watch) return prs, nil } // fetchReviewPRsWithFilter queries each repo filter individually and deduplicates results. // When customQuery is set, the repo qualifier is appended to it; otherwise scope+filter is used. -func (s *Service) fetchReviewPRsWithFilter(ctx context.Context, watch *ReviewWatch) []*PR { +func (s *Service) fetchReviewPRsWithFilter(ctx context.Context, client Client, watch *ReviewWatch) []*PR { var allPRs []*PR seen := make(map[string]bool) @@ -335,18 +408,18 @@ func (s *Service) fetchReviewPRsWithFilter(ctx context.Context, watch *ReviewWat var prs []*PR var err error - if watch.CustomQuery != "" { - query := watch.CustomQuery + " " + qualifier + if queryBase := reviewWatchQuery(watch); queryBase != "" { + query := queryBase + " " + qualifier s.logger.Debug("fetchReviewPRs: querying with custom query + filter", zap.String("watch_id", watch.ID), zap.String("query", query)) - prs, err = s.client.ListReviewRequestedPRs(ctx, "", "", query) + prs, err = client.ListReviewRequestedPRs(ctx, "", "", query) } else { s.logger.Debug("fetchReviewPRs: querying with scope + filter", zap.String("watch_id", watch.ID), zap.String("scope", watch.ReviewScope), zap.String("filter", qualifier)) - prs, err = s.client.ListReviewRequestedPRs(ctx, watch.ReviewScope, qualifier, "") + prs, err = client.ListReviewRequestedPRs(ctx, watch.ReviewScope, qualifier, "") } if err != nil { if isConnectivityError(err) { @@ -374,6 +447,22 @@ func (s *Service) fetchReviewPRsWithFilter(ctx context.Context, watch *ReviewWat return allPRs } +func reviewWatchQuery(watch *ReviewWatch) string { + query := strings.TrimSpace(watch.CustomQuery) + login := strings.TrimSpace(watch.TargetLogin) + if login == "" { + return query + } + qualifier := "review-requested:" + login + if watch.ReviewScope == ReviewScopeUser { + qualifier = "user-review-requested:" + login + } + if query != "" { + return query + " " + qualifier + } + return "type:pr state:open " + qualifier + " -is:draft" +} + // repoFilterToQualifier converts a RepoFilter to a GitHub search qualifier string. func repoFilterToQualifier(repo RepoFilter) string { if repo.Name == "" { @@ -383,7 +472,7 @@ func repoFilterToQualifier(repo RepoFilter) string { } // enrichPRDetails fetches full PR details for PRs missing branch info (from the search API). -func (s *Service) enrichPRDetails(ctx context.Context, prs []*PR) { +func (s *Service) enrichPRDetails(ctx context.Context, client Client, prs []*PR) { for _, pr := range prs { if pr.HeadBranch != "" && pr.BaseBranch != "" { continue @@ -392,7 +481,7 @@ func (s *Service) enrichPRDetails(ctx context.Context, prs []*PR) { zap.String("repo", pr.RepoOwner+"/"+pr.RepoName), zap.Int("pr_number", pr.Number)) - full, err := s.client.GetPR(ctx, pr.RepoOwner, pr.RepoName, pr.Number) + full, err := client.GetPR(ctx, pr.RepoOwner, pr.RepoName, pr.Number) if err != nil { s.logger.Warn("failed to fetch full PR details, branch info will be empty", zap.String("repo", pr.RepoOwner+"/"+pr.RepoName), @@ -414,12 +503,26 @@ func (s *Service) ListUserOrgs(ctx context.Context) ([]GitHubOrg, error) { if s.client == nil { return nil, fmt.Errorf("github client not available") } - orgs, err := s.client.ListUserOrgs(ctx) + return listUserOrgs(ctx, s.client) +} + +func (s *Service) ListUserOrgsForWorkspace( + ctx context.Context, workspaceID, userID string, +) ([]GitHubOrg, error) { + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, "", "") + if err != nil { + return nil, err + } + return listUserOrgs(ctx, resolved.Client) +} + +func listUserOrgs(ctx context.Context, client Client) ([]GitHubOrg, error) { + orgs, err := client.ListUserOrgs(ctx) if err != nil { return nil, err } // Prepend the authenticated user as a pseudo-org (for personal repos). - user, userErr := s.client.GetAuthenticatedUser(ctx) + user, userErr := client.GetAuthenticatedUser(ctx) if userErr == nil && user != "" { orgs = append([]GitHubOrg{{Login: user}}, orgs...) } @@ -434,6 +537,30 @@ func (s *Service) SearchOrgRepos(ctx context.Context, org, query string, limit i return s.client.SearchOrgRepos(ctx, org, query, limit) } +func (s *Service) SearchOrgReposForWorkspace( + ctx context.Context, workspaceID, org, query string, limit int, +) ([]GitHubRepo, error) { + resolved, err := s.resolveAutomationClient(ctx, workspaceID, org, "") + if err != nil { + return nil, err + } + settings, err := s.GetWorkspaceSettings(ctx, workspaceID) + if err != nil { + return nil, err + } + repos, err := resolved.Client.SearchOrgRepos(ctx, org, query, limit) + if err != nil { + return nil, err + } + filtered := repos[:0] + for _, repo := range repos { + if repositoryInWorkspaceScope(settings, repo.Owner, repo.Name) { + filtered = append(filtered, repo) + } + } + return filtered, nil +} + // ListRepoBranches lists branches for a repository. // When no authenticated client is configured, it falls back to the unauthenticated // GitHub API so that public repositories remain accessible without a token. @@ -461,6 +588,24 @@ func (s *Service) ListRepoBranches(ctx context.Context, owner, repo string) ([]R return branches, nil } +func (s *Service) ListRepoBranchesForWorkspace( + ctx context.Context, workspaceID, owner, repo string, +) ([]RepoBranch, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + resolved, err := s.resolveAutomationClient(ctx, workspaceID, owner, repo) + if err != nil { + return nil, err + } + branches, err := resolved.Client.ListRepoBranches(ctx, owner, repo) + if err != nil { + return nil, err + } + sortBranchesMainFirst(branches) + return branches, nil +} + // sortBranchesMainFirst sorts branches in-place: "main" first, "master" second, // then all remaining branches alphabetically. func sortBranchesMainFirst(branches []RepoBranch) { @@ -608,6 +753,24 @@ func (s *Service) SearchUserPRs(ctx context.Context, filter, customQuery string) return s.client.SearchPRs(ctx, filter, customQuery) } +func (s *Service) SearchUserPRsForWorkspace( + ctx context.Context, workspaceID, userID, filter, customQuery string, +) ([]*PR, error) { + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, "", "") + if err != nil { + return nil, err + } + settings, err := s.GetWorkspaceSettings(ctx, workspaceID) + if err != nil { + return nil, err + } + prs, err := resolved.Client.SearchPRs(ctx, filter, customQuery) + if err != nil { + return nil, err + } + return s.filterPersonalPRsByAutomation(ctx, workspaceID, filterPRsByWorkspaceScope(prs, settings)) +} + // SearchUserIssues searches for issues using a filter or custom query. Unless // the caller already pins a type qualifier, `type:issue` is injected into the // composed query. @@ -618,6 +781,39 @@ func (s *Service) SearchUserIssues(ctx context.Context, filter, customQuery stri return s.client.ListIssues(ctx, filter, customQuery) } +func (s *Service) SearchUserIssuesForWorkspace( + ctx context.Context, workspaceID, userID, filter, customQuery string, +) ([]*Issue, error) { + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, "", "") + if err != nil { + return nil, err + } + settings, err := s.GetWorkspaceSettings(ctx, workspaceID) + if err != nil { + return nil, err + } + issues, err := resolved.Client.ListIssues(ctx, filter, customQuery) + if err != nil { + return nil, err + } + return s.filterPersonalIssuesByAutomation(ctx, workspaceID, filterIssuesByWorkspaceScope(issues, settings)) +} + +// ListAutomationPRs is the background-automation search path. It always uses +// the workspace automation identity and enforces the configured repo boundary. +func (s *Service) ListAutomationPRs( + ctx context.Context, workspaceID, owner, repo, customQuery string, +) ([]*PR, error) { + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + resolved, err := s.resolveAutomationClient(ctx, workspaceID, owner, repo) + if err != nil { + return nil, err + } + return resolved.Client.ListReviewRequestedPRs(ctx, "", "", customQuery) +} + // SearchUserPRsPaged is the paginated variant of SearchUserPRs. Results are // cached for a short window (see searchCacheTTL) — callers must not mutate // the returned page, since it is shared across concurrent requests. @@ -625,13 +821,19 @@ func (s *Service) SearchUserPRsPaged(ctx context.Context, filter, customQuery st if s.client == nil { return nil, fmt.Errorf("github client not available") } + return s.searchUserPRsPagedWithClient(ctx, s.client, "legacy", filter, customQuery, page, perPage) +} + +func (s *Service) searchUserPRsPagedWithClient( + ctx context.Context, client Client, cacheScope, filter, customQuery string, page, perPage int, +) (*PRSearchPage, error) { // Clamp before composing the cache key so perPage=150 and perPage=100 // share the same cached page instead of creating two entries for // identical results. page, perPage = clampSearchPage(page, perPage) - key := searchCacheKey("pr", filter, customQuery, page, perPage) + key := scopedCacheKey(cacheScope, searchCacheKey("pr", filter, customQuery, page, perPage)) v, err := s.searchCache.doOrFetch(key, func() (any, error) { - result, err := s.client.SearchPRsPaged(ctx, filter, customQuery, page, perPage) + result, err := client.SearchPRsPaged(ctx, filter, customQuery, page, perPage) if err != nil { return nil, err } @@ -652,10 +854,16 @@ func (s *Service) SearchUserIssuesPaged(ctx context.Context, filter, customQuery if s.client == nil { return nil, fmt.Errorf("github client not available") } + return s.searchUserIssuesPagedWithClient(ctx, s.client, "legacy", filter, customQuery, page, perPage) +} + +func (s *Service) searchUserIssuesPagedWithClient( + ctx context.Context, client Client, cacheScope, filter, customQuery string, page, perPage int, +) (*IssueSearchPage, error) { page, perPage = clampSearchPage(page, perPage) - key := searchCacheKey("issue", filter, customQuery, page, perPage) + key := scopedCacheKey(cacheScope, searchCacheKey("issue", filter, customQuery, page, perPage)) v, err := s.searchCache.doOrFetch(key, func() (any, error) { - result, err := s.client.ListIssuesPaged(ctx, filter, customQuery, page, perPage) + result, err := client.ListIssuesPaged(ctx, filter, customQuery, page, perPage) if err != nil { return nil, err } diff --git a/apps/backend/internal/github/service_reviews_auth_test.go b/apps/backend/internal/github/service_reviews_auth_test.go new file mode 100644 index 0000000000..053f761f2c --- /dev/null +++ b/apps/backend/internal/github/service_reviews_auth_test.go @@ -0,0 +1,117 @@ +package github + +import ( + "context" + "errors" + "testing" +) + +type reviewWatchConnectionReader struct { + workspace *WorkspaceConnection + user *UserConnection + err error +} + +func (r reviewWatchConnectionReader) GetWorkspaceConnection( + context.Context, string, +) (*WorkspaceConnection, error) { + return r.workspace, r.err +} + +func (r reviewWatchConnectionReader) GetUserConnection( + context.Context, string, string, +) (*UserConnection, error) { + return r.user, r.err +} + +func TestCreateReviewWatchForUserRequiresWorkspace(t *testing.T) { + service := &Service{} + for _, request := range []*CreateReviewWatchRequest{nil, {}} { + _, err := service.CreateReviewWatchForUser(context.Background(), "user-1", request) + if !errors.Is(err, ErrGitHubWorkspaceRequired) { + t.Fatalf("CreateReviewWatchForUser() error = %v, want workspace required", err) + } + } +} + +func TestCreateReviewWatchForUserPropagatesResolutionFailureWithoutCreatingWatch(t *testing.T) { + store := newTestStore(t) + service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + t.Cleanup(service.Stop) + wantErr := errors.New("connection lookup failed") + service.resolver = NewCredentialResolver(reviewWatchConnectionReader{err: wantErr}, nil) + + _, err := service.CreateReviewWatchForUser( + context.Background(), "user-1", &CreateReviewWatchRequest{WorkspaceID: "workspace-1"}, + ) + if !errors.Is(err, wantErr) { + t.Fatalf("CreateReviewWatchForUser() error = %v, want %v", err, wantErr) + } + assertNoReviewWatches(t, store, "workspace-1") +} + +func TestCreateReviewWatchForUserRequiresVerifiedLogin(t *testing.T) { + service, store := reviewWatchAuthTestService(t, "") + + _, err := service.CreateReviewWatchForUser( + context.Background(), "user-1", &CreateReviewWatchRequest{WorkspaceID: "workspace-1"}, + ) + if !errors.Is(err, ErrGitHubPersonalRequired) { + t.Fatalf("CreateReviewWatchForUser() error = %v, want personal required", err) + } + assertNoReviewWatches(t, store, "workspace-1") +} + +func TestCreateReviewWatchForUserPersistsResolvedTargetLogin(t *testing.T) { + service, store := reviewWatchAuthTestService(t, "octocat") + + watch, err := service.CreateReviewWatchForUser( + context.Background(), "user-1", &CreateReviewWatchRequest{WorkspaceID: "workspace-1"}, + ) + if err != nil { + t.Fatalf("CreateReviewWatchForUser(): %v", err) + } + if watch.TargetLogin != "octocat" { + t.Fatalf("TargetLogin = %q, want octocat", watch.TargetLogin) + } + stored, err := store.GetReviewWatch(context.Background(), watch.ID) + if err != nil { + t.Fatalf("GetReviewWatch(): %v", err) + } + if stored == nil || stored.TargetLogin != "octocat" { + t.Fatalf("stored watch = %+v, want target login octocat", stored) + } +} + +func reviewWatchAuthTestService(t *testing.T, login string) (*Service, *Store) { + t.Helper() + store := newTestStore(t) + client := NewMockClient() + service := NewService(client, AuthMethodNone, nil, store, nil, testLogger(t)) + t.Cleanup(service.Stop) + reader := reviewWatchConnectionReader{ + workspace: &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourcePAT, + Status: ConnectionStatusActive, CredentialGeneration: 1, + }, + user: &UserConnection{ + WorkspaceID: "workspace-1", UserID: "user-1", Login: login, + Status: ConnectionStatusActive, CredentialGeneration: 1, + }, + } + service.resolver = NewCredentialResolver(reader, nil) + service.resolver.SetUserProvider(fixedUserCredentialProvider{client: client}) + service.resolver.SetAutomationProvider(testAutomationCredentialProvider{client: client}) + return service, store +} + +func assertNoReviewWatches(t *testing.T, store *Store, workspaceID string) { + t.Helper() + watches, err := store.ListReviewWatches(context.Background(), workspaceID) + if err != nil { + t.Fatalf("ListReviewWatches(): %v", err) + } + if len(watches) != 0 { + t.Fatalf("review watches = %+v, want none", watches) + } +} diff --git a/apps/backend/internal/github/service_routing.go b/apps/backend/internal/github/service_routing.go new file mode 100644 index 0000000000..bf7de65ec3 --- /dev/null +++ b/apps/backend/internal/github/service_routing.go @@ -0,0 +1,312 @@ +package github + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" +) + +// resolvedServiceClient is the operation-scoped GitHub client selected for a +// workspace and purpose. The cache namespace must accompany the client so +// service-level caches never coalesce results across credentials. +type resolvedServiceClient struct { + Client Client + Principal AuthPrincipal + CacheScope string + Capabilities map[GitHubAppCapability]bool + credential *ResolvedCredential +} + +// repositoryAccessChecker performs a read-only repository-root probe. It is +// intentionally separate from merge-method discovery so an authorization +// check cannot populate operation caches or consume operation-specific test +// seams. +type repositoryAccessChecker interface { + HasRepositoryAccess(context.Context, string, string) (bool, error) +} + +const automationVisibilityConcurrency = 8 + +func requireGitHubCapability(resolved *resolvedServiceClient, capability GitHubAppCapability) error { + if resolved == nil { + return ErrGitHubNotConfigured + } + // Human PAT, CLI, and App-user OAuth permissions are ultimately enforced + // by GitHub for that user. Installation principals have an explicit, + // verifiable permission map and must fail before making the provider call. + if resolved.Principal.Kind == AuthPrincipalApp && !resolved.Capabilities[capability] { + return fmt.Errorf("%w: %s", ErrGitHubCapabilityDenied, capability) + } + return nil +} + +func (s *Service) resolveServiceClient( + ctx context.Context, + workspaceID string, + userID string, + purpose CredentialPurpose, + owner string, + repo string, +) (*resolvedServiceClient, error) { + if strings.TrimSpace(workspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } + if s == nil || s.resolver == nil { + return nil, ErrGitHubNotConfigured + } + resolved, err := s.resolver.Resolve(ctx, ResolveCredentialRequest{ + WorkspaceID: workspaceID, + UserID: userID, + Purpose: purpose, + RepoOwner: owner, + RepoName: repo, + }) + if err != nil { + return nil, err + } + if resolved == nil || resolved.Client == nil { + return nil, ErrGitHubNotConfigured + } + return &resolvedServiceClient{ + Client: resolved.Client, + Principal: resolved.Principal, + CacheScope: credentialCacheScope(resolved, purpose), + Capabilities: resolved.Capabilities, + credential: resolved, + }, nil +} + +// cacheScopeForPurpose keeps cache invalidation tied to the same resolved +// credential identity while selecting the namespace used by another operation +// purpose. Personal reads and writes intentionally have distinct namespaces. +func (r *resolvedServiceClient) cacheScopeForPurpose(purpose CredentialPurpose) string { + if r == nil || r.credential == nil { + return "" + } + return credentialCacheScope(r.credential, purpose) +} + +func credentialCacheScope(resolved *ResolvedCredential, purpose CredentialPurpose) string { + principal := resolved.Principal + return fmt.Sprintf( + "%d:%s|%d:%s|%d:%s|%d:%s|%d:%s|%d|%d|%d:%s|%d|%d:%s", + len(principal.WorkspaceID), principal.WorkspaceID, + len(principal.UserID), principal.UserID, + len(principal.Source), principal.Source, + len(principal.Kind), principal.Kind, + len(principal.Login), principal.Login, + principal.InstallationID, resolved.CredentialGeneration, + len(resolved.AppRegistrationID), resolved.AppRegistrationID, + resolved.AppCredentialGeneration, + len(purpose), purpose, + ) +} + +func scopedCacheKey(scope, key string) string { + return fmt.Sprintf("%d:%s|%s", len(scope), scope, key) +} + +func (s *Service) isRepoCachedAsMissingForScope(scope, owner, repo string) bool { + if s == nil || s.repoErrorCache == nil { + return false + } + v, ok := s.repoErrorCache.get(scopedCacheKey(scope, repoErrorCacheKey(owner, repo))) + if !ok { + return false + } + _, isErr := v.(cachedErr) + return isErr +} + +func (s *Service) markRepoAsMissingForScope(scope, owner, repo string, gen uint64) { + if s == nil || s.repoErrorCache == nil { + return + } + s.repoErrorCache.setIfCurrentGeneration( + scopedCacheKey(scope, repoErrorCacheKey(owner, repo)), + cachedErr{err: ErrRepoNotResolvable}, + gen, + ) +} + +func (s *Service) resolveAutomationClient( + ctx context.Context, + workspaceID string, + owner string, + repo string, +) (*resolvedServiceClient, error) { + return s.resolveServiceClient(ctx, workspaceID, "", CredentialPurposeAutomation, owner, repo) +} + +func (s *Service) resolvePersonalReadClient( + ctx context.Context, + workspaceID string, + userID string, + owner string, + repo string, +) (*resolvedServiceClient, error) { + if err := s.ensurePersonalRepositoryBoundary(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + return s.resolveServiceClient(ctx, workspaceID, userID, CredentialPurposePersonalRead, owner, repo) +} + +func (s *Service) resolvePersonalWriteClient( + ctx context.Context, + workspaceID string, + userID string, + owner string, + repo string, +) (*resolvedServiceClient, error) { + if err := s.ensurePersonalRepositoryBoundary(ctx, workspaceID, owner, repo); err != nil { + return nil, err + } + return s.resolveServiceClient(ctx, workspaceID, userID, CredentialPurposePersonalWrite, owner, repo) +} + +func (s *Service) ensurePersonalRepositoryBoundary( + ctx context.Context, + workspaceID, owner, repo string, +) error { + if strings.TrimSpace(owner) == "" || strings.TrimSpace(repo) == "" { + return nil + } + if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { + return err + } + automation, err := s.resolveAutomationClient(ctx, workspaceID, owner, repo) + if err != nil { + return err + } + checker, ok := automation.Client.(repositoryAccessChecker) + if !ok { + return fmt.Errorf("verify automation repository access for %s/%s: unsupported client", owner, repo) + } + visible, err := checker.HasRepositoryAccess(ctx, owner, repo) + if err == nil && visible { + return nil + } + var apiErr *GitHubAPIError + if err == nil || isRepoNotResolvableErr(err) || (errors.As(err, &apiErr) && apiErr.StatusCode == 404) { + return fmt.Errorf("%w: automation connection cannot access %s/%s", ErrRepoNotResolvable, owner, repo) + } + return fmt.Errorf("verify automation repository access for %s/%s: %w", owner, repo, err) +} + +// automationRepositoryVisibility returns the exact subset of repository +// identities visible to the workspace automation principal. Personal list +// and batch operations use it after provider search so a broad user token +// cannot reveal repositories outside an App installation. +func (s *Service) automationRepositoryVisibility( + ctx context.Context, + workspaceID string, + repositories []RepoFilter, +) (map[string]bool, error) { + resolved, err := s.resolveAutomationClient(ctx, workspaceID, "", "") + if err != nil { + return nil, err + } + checker, ok := resolved.Client.(repositoryAccessChecker) + if !ok { + return nil, errors.New("GitHub automation client cannot verify repository access") + } + + unique := make(map[string]RepoFilter, len(repositories)) + for _, repository := range repositories { + key := repositoryIdentityKey(repository.Owner, repository.Name) + if key != "/" && key != "" { + unique[key] = repository + } + } + visible := make(map[string]bool, len(unique)) + var resultMu sync.Mutex + var firstErr error + sem := make(chan struct{}, automationVisibilityConcurrency) + var wg sync.WaitGroup + for key, repository := range unique { + wg.Add(1) + go func(key string, repository RepoFilter) { + defer wg.Done() + select { + case sem <- struct{}{}: + case <-ctx.Done(): + resultMu.Lock() + if firstErr == nil { + firstErr = ctx.Err() + } + resultMu.Unlock() + return + } + defer func() { <-sem }() + allowed, checkErr := checker.HasRepositoryAccess(ctx, repository.Owner, repository.Name) + if checkErr != nil && !repositoryAccessDenied(checkErr) { + resultMu.Lock() + if firstErr == nil { + firstErr = checkErr + } + resultMu.Unlock() + return + } + resultMu.Lock() + visible[key] = checkErr == nil && allowed + resultMu.Unlock() + }(key, repository) + } + wg.Wait() + return visible, firstErr +} + +func repositoryAccessDenied(err error) bool { + if err == nil || isRepoNotResolvableErr(err) { + return err != nil + } + var apiErr *GitHubAPIError + return errors.As(err, &apiErr) && apiErr.StatusCode == 404 +} + +func repositoryIdentityKey(owner, repo string) string { + return strings.ToLower(strings.TrimSpace(owner)) + "/" + strings.ToLower(strings.TrimSpace(repo)) +} + +// ensureRepositoryInWorkspaceScope enforces Kandev's configured repository +// boundary before a provider call. GitHub App installation visibility is +// enforced by GitHub itself, so the two checks form an intersection. +func (s *Service) ensureRepositoryInWorkspaceScope(ctx context.Context, workspaceID, owner, repo string) error { + if strings.TrimSpace(owner) == "" || strings.TrimSpace(repo) == "" { + return nil + } + settings, err := s.GetWorkspaceSettings(ctx, workspaceID) + if err != nil { + return err + } + if repositoryInWorkspaceScope(settings, owner, repo) { + return nil + } + return ErrRepoNotResolvable +} + +func repositoryInWorkspaceScope(settings *WorkspaceSettings, owner, repo string) bool { + if settings == nil || settings.RepoScopeMode == "" || settings.RepoScopeMode == RepoScopeModeAll { + return true + } + switch settings.RepoScopeMode { + case RepoScopeModeOrgs: + for _, org := range settings.RepoScopeOrgs { + if strings.EqualFold(strings.TrimSpace(org), strings.TrimSpace(owner)) { + return true + } + } + case RepoScopeModeRepos: + for _, candidate := range settings.RepoScopeRepos { + if strings.EqualFold(candidate.Owner, owner) && strings.EqualFold(candidate.Name, repo) { + return true + } + } + } + return false +} diff --git a/apps/backend/internal/github/service_routing_test.go b/apps/backend/internal/github/service_routing_test.go new file mode 100644 index 0000000000..171bc9b0c3 --- /dev/null +++ b/apps/backend/internal/github/service_routing_test.go @@ -0,0 +1,464 @@ +package github + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +type repositoryVisibilityClient struct { + Client + repos map[string]GitHubRepo + prs []*PR + issues []*Issue + paginate bool +} + +func (c repositoryVisibilityClient) SearchPRs(context.Context, string, string) ([]*PR, error) { + return append([]*PR(nil), c.prs...), nil +} + +func (c repositoryVisibilityClient) SearchPRsPaged( + _ context.Context, _, _ string, page, perPage int, +) (*PRSearchPage, error) { + prs := append([]*PR(nil), c.prs...) + if c.paginate { + prs = paginateSearchResults(prs, page, perPage) + } + return &PRSearchPage{PRs: prs, TotalCount: len(c.prs), Page: page, PerPage: perPage}, nil +} + +func (c repositoryVisibilityClient) ListIssues(context.Context, string, string) ([]*Issue, error) { + return append([]*Issue(nil), c.issues...), nil +} + +func (c repositoryVisibilityClient) ListIssuesPaged( + _ context.Context, _, _ string, page, perPage int, +) (*IssueSearchPage, error) { + issues := append([]*Issue(nil), c.issues...) + if c.paginate { + issues = paginateSearchResults(issues, page, perPage) + } + return &IssueSearchPage{ + Issues: issues, TotalCount: len(c.issues), Page: page, PerPage: perPage, + }, nil +} + +func (c repositoryVisibilityClient) GetPRStatus( + _ context.Context, owner, repo string, number int, +) (*PRStatus, error) { + return &PRStatus{PR: &PR{RepoOwner: owner, RepoName: repo, Number: number}}, nil +} + +func (c repositoryVisibilityClient) HasRepositoryAccess( + _ context.Context, owner, repo string, +) (bool, error) { + if _, ok := c.repos[strings.ToLower(owner+"/"+repo)]; !ok { + return false, &GitHubAPIError{StatusCode: 404, Endpoint: "/repos/" + owner + "/" + repo} + } + return true, nil +} + +func (c repositoryVisibilityClient) ListAccessibleRepos( + _ context.Context, query string, _ int, +) ([]GitHubRepo, error) { + result := make([]GitHubRepo, 0, len(c.repos)) + for _, repo := range c.repos { + if query == "" || strings.Contains(strings.ToLower(repo.FullName), strings.ToLower(query)) { + result = append(result, repo) + } + } + return result, nil +} + +type fixedUserCredentialProvider struct{ client Client } + +func (p fixedUserCredentialProvider) ResolveUser( + _ context.Context, connection *UserConnection, _ ResolveCredentialRequest, +) (*ResolvedCredential, error) { + return &ResolvedCredential{ + Client: p.client, + Principal: AuthPrincipal{ + Kind: AuthPrincipalHuman, Source: ConnectionSourceGitHubAppUser, Login: connection.Login, + }, + }, nil +} + +func TestResolveServiceClientRequiresWorkspace(t *testing.T) { + svc := &Service{} + _, err := svc.resolveAutomationClient(context.Background(), "", "octo", "repo") + if !errors.Is(err, ErrGitHubWorkspaceRequired) { + t.Fatalf("expected workspace required, got %v", err) + } +} + +func TestResolveServiceClientAuthorizesWorkspaceBeforeCredentialLookup(t *testing.T) { + want := errors.New("workspace not found") + svc := &Service{workspaceAuthorizer: func(_ context.Context, workspaceID string) error { + if workspaceID != "victim-workspace" { + t.Fatalf("workspaceID = %q", workspaceID) + } + return want + }} + + _, err := svc.resolveServiceClient( + context.Background(), "victim-workspace", "attacker", CredentialPurposePersonalRead, "", "", + ) + if !errors.Is(err, want) { + t.Fatalf("resolveServiceClient() error = %v, want authorization denial", err) + } +} + +func TestCredentialCacheScopeSeparatesWorkspacePrincipalAndGeneration(t *testing.T) { + base := &ResolvedCredential{ + Principal: AuthPrincipal{ + WorkspaceID: "workspace-a", + UserID: "user-a", + Source: ConnectionSourcePAT, + Kind: AuthPrincipalHuman, + Login: "octocat", + }, + CredentialGeneration: 1, + AppRegistrationID: "registration-a", + AppCredentialGeneration: 7, + } + baseScope := credentialCacheScope(base, CredentialPurposePersonalRead) + + variants := []*ResolvedCredential{ + {Principal: AuthPrincipal{WorkspaceID: "workspace-b", UserID: "user-a", Source: ConnectionSourcePAT, Kind: AuthPrincipalHuman, Login: "octocat"}, CredentialGeneration: 1, AppRegistrationID: "registration-a", AppCredentialGeneration: 7}, + {Principal: AuthPrincipal{WorkspaceID: "workspace-a", UserID: "user-b", Source: ConnectionSourcePAT, Kind: AuthPrincipalHuman, Login: "octocat"}, CredentialGeneration: 1, AppRegistrationID: "registration-a", AppCredentialGeneration: 7}, + {Principal: base.Principal, CredentialGeneration: 2, AppRegistrationID: "registration-a", AppCredentialGeneration: 7}, + {Principal: base.Principal, CredentialGeneration: 1, AppRegistrationID: "registration-b", AppCredentialGeneration: 7}, + {Principal: base.Principal, CredentialGeneration: 1, AppRegistrationID: "registration-a", AppCredentialGeneration: 8}, + } + for _, variant := range variants { + if got := credentialCacheScope(variant, CredentialPurposePersonalRead); got == baseScope { + t.Fatalf("cache scope collision: %q", got) + } + } + if got := credentialCacheScope(base, CredentialPurposePersonalWrite); got == baseScope { + t.Fatalf("purpose must be part of cache scope: %q", got) + } +} + +func TestRepositoryInWorkspaceScope(t *testing.T) { + tests := []struct { + name string + settings *WorkspaceSettings + owner string + repo string + want bool + }{ + {name: "all", settings: &WorkspaceSettings{RepoScopeMode: RepoScopeModeAll}, owner: "octo", repo: "one", want: true}, + {name: "org match", settings: &WorkspaceSettings{RepoScopeMode: RepoScopeModeOrgs, RepoScopeOrgs: []string{"Octo"}}, owner: "octo", repo: "one", want: true}, + {name: "org miss", settings: &WorkspaceSettings{RepoScopeMode: RepoScopeModeOrgs, RepoScopeOrgs: []string{"other"}}, owner: "octo", repo: "one"}, + {name: "repo match", settings: &WorkspaceSettings{RepoScopeMode: RepoScopeModeRepos, RepoScopeRepos: []RepoFilter{{Owner: "Octo", Name: "One"}}}, owner: "octo", repo: "one", want: true}, + {name: "empty fails closed", settings: &WorkspaceSettings{RepoScopeMode: RepoScopeModeRepos}, owner: "octo", repo: "one"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := repositoryInWorkspaceScope(tt.settings, tt.owner, tt.repo); got != tt.want { + t.Fatalf("repositoryInWorkspaceScope() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestReviewWatchQueryUsesPersistedTargetLogin(t *testing.T) { + tests := []struct { + name string + watch *ReviewWatch + want string + }{ + { + name: "user and teams", + watch: &ReviewWatch{ReviewScope: ReviewScopeUserAndTeams, TargetLogin: "octocat"}, + want: "type:pr state:open review-requested:octocat -is:draft", + }, + { + name: "user only", + watch: &ReviewWatch{ReviewScope: ReviewScopeUser, TargetLogin: "octocat"}, + want: "type:pr state:open user-review-requested:octocat -is:draft", + }, + { + name: "custom query", + watch: &ReviewWatch{CustomQuery: "is:open label:urgent", TargetLogin: "octocat"}, + want: "is:open label:urgent review-requested:octocat", + }, + { + name: "human legacy remains current user", + watch: &ReviewWatch{CustomQuery: "is:open"}, + want: "is:open", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := reviewWatchQuery(tt.watch); got != tt.want { + t.Fatalf("reviewWatchQuery() = %q, want %q", got, tt.want) + } + }) + } +} + +type rejectingInstallationProvider struct { + t *testing.T +} + +func (p rejectingInstallationProvider) ResolveInstallation( + context.Context, *WorkspaceConnection, ResolveCredentialRequest, +) (*ResolvedCredential, error) { + p.t.Fatal("installation provider called for a review watch without target_login") + return nil, nil +} + +func TestCheckReviewWatchDisablesAppWatchWithoutTargetBeforeProviderCall(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + if _, err := store.db.Exec(`CREATE TABLE IF NOT EXISTS workspaces (id TEXT PRIMARY KEY)`); err != nil { + t.Fatalf("create workspaces table: %v", err) + } + if _, err := store.db.Exec(`INSERT INTO workspaces (id) VALUES ('workspace-app')`); err != nil { + t.Fatalf("seed workspace: %v", err) + } + installationID := int64(123) + if err := store.UpsertDeploymentAppRegistration( + ctx, newAppRegistration("registration-watch", 123, "Watch App", time.Now().UTC()), + ); err != nil { + t.Fatalf("seed App registration: %v", err) + } + if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ + WorkspaceID: "workspace-app", + Source: ConnectionSourceGitHubAppInstallation, + Status: ConnectionStatusActive, + GitHubHost: defaultGitHubHost, + InstallationID: &installationID, + InstallationAccountLogin: "octo-org", + InstallationAccountType: "Organization", + AppRegistrationID: "registration-watch", + }); err != nil { + t.Fatalf("upsert workspace connection: %v", err) + } + watch := &ReviewWatch{ + ID: "watch-app", + WorkspaceID: "workspace-app", + Enabled: true, + PollIntervalSeconds: minWatchPollIntervalSec, + } + if err := store.CreateReviewWatch(ctx, watch); err != nil { + t.Fatalf("create review watch: %v", err) + } + svc := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + svc.resolver.SetInstallationProvider(rejectingInstallationProvider{t: t}) + + _, err := svc.CheckReviewWatch(ctx, watch) + if !errors.Is(err, ErrGitHubPersonalRequired) { + t.Fatalf("CheckReviewWatch error = %v, want personal required", err) + } + stored, err := store.GetReviewWatch(ctx, watch.ID) + if err != nil { + t.Fatalf("get review watch: %v", err) + } + if stored.Enabled || stored.LastError == "" { + t.Fatalf("watch was not disabled with actionable error: %+v", stored) + } +} + +func TestSyncWorkspaceWatchesBatchedRejectsMixedOwnershipBeforeResolve(t *testing.T) { + svc := &Service{} + _, err := svc.SyncWorkspaceWatchesBatched(context.Background(), "workspace-a", []*PRWatch{ + {WorkspaceID: "workspace-a"}, + {WorkspaceID: "workspace-b"}, + }) + if !errors.Is(err, ErrGitHubWorkspaceRequired) { + t.Fatalf("error = %v, want workspace ownership error", err) + } +} + +func TestRequireGitHubCapabilityFailsClosedForAppOnly(t *testing.T) { + app := &resolvedServiceClient{ + Principal: AuthPrincipal{Kind: AuthPrincipalApp}, + Capabilities: map[GitHubAppCapability]bool{CapabilityPullRequestRead: true}, + } + if err := requireGitHubCapability(app, CapabilityPullRequestWrite); !errors.Is(err, ErrGitHubCapabilityDenied) { + t.Fatalf("app capability error = %v", err) + } + human := &resolvedServiceClient{Principal: AuthPrincipal{Kind: AuthPrincipalHuman}} + if err := requireGitHubCapability(human, CapabilityPullRequestWrite); err != nil { + t.Fatalf("human token should defer permissions to GitHub: %v", err) + } +} + +func TestPersonalRepositoryResolutionRequiresAutomationVisibility(t *testing.T) { + service := personalBoundaryTestService(t, + map[string]GitHubRepo{"acme/allowed": testGitHubRepo("acme", "allowed")}, + map[string]GitHubRepo{ + "acme/allowed": testGitHubRepo("acme", "allowed"), + "personal-only/repo": testGitHubRepo("personal-only", "repo"), + }, + ) + + _, err := service.resolvePersonalReadClient( + context.Background(), "workspace-1", "user-1", "personal-only", "repo", + ) + if !errors.Is(err, ErrRepoNotResolvable) { + t.Fatalf("resolvePersonalReadClient() error = %v, want repository boundary denial", err) + } + if _, err := service.resolvePersonalReadClient( + context.Background(), "workspace-1", "user-1", "acme", "allowed", + ); err != nil { + t.Fatalf("allowed repository resolution failed: %v", err) + } +} + +func TestPersonalAccessibleReposIntersectAutomationVisibility(t *testing.T) { + service := personalBoundaryTestService(t, + map[string]GitHubRepo{"acme/allowed": testGitHubRepo("acme", "allowed")}, + map[string]GitHubRepo{ + "acme/allowed": testGitHubRepo("acme", "allowed"), + "personal-only/repo": testGitHubRepo("personal-only", "repo"), + }, + ) + + repos, err := service.ListAccessibleReposForWorkspace( + context.Background(), "workspace-1", "user-1", "", 50, + ) + if err != nil { + t.Fatalf("ListAccessibleReposForWorkspace() error = %v", err) + } + if len(repos) != 1 || repos[0].FullName != "acme/allowed" { + t.Fatalf("accessible repositories = %+v, want only automation-visible repository", repos) + } +} + +func TestPersonalSearchAndStatusResultsIntersectAutomationVisibility(t *testing.T) { + service := personalBoundaryTestService(t, + map[string]GitHubRepo{"acme/allowed": testGitHubRepo("acme", "allowed")}, + map[string]GitHubRepo{ + "acme/allowed": testGitHubRepo("acme", "allowed"), + "personal-only/repo": testGitHubRepo("personal-only", "repo"), + }, + ) + ctx := context.Background() + + prs, err := service.SearchUserPRsForWorkspace(ctx, "workspace-1", "user-1", "", "") + if err != nil || len(prs) != 1 || prs[0].RepoName != "allowed" { + t.Fatalf("unpaged PR search = %+v, %v", prs, err) + } + issues, err := service.SearchUserIssuesPagedForWorkspaceUser( + ctx, "workspace-1", "user-1", "", "", 1, 50, + ) + if err != nil || len(issues.Issues) != 1 || issues.TotalCount != 1 || issues.Issues[0].RepoName != "allowed" { + t.Fatalf("paged issue search = %+v, %v", issues, err) + } + statuses, err := service.GetPRStatusesBatchForWorkspace(ctx, "workspace-1", "user-1", []PRRef{ + {Owner: "acme", Repo: "allowed", Number: 1}, + {Owner: "personal-only", Repo: "repo", Number: 2}, + }) + if err != nil { + t.Fatalf("status batch error = %v", err) + } + if len(statuses) != 1 || statuses[prStatusCacheKey("acme", "allowed", 1)] == nil { + t.Fatalf("status batch = %+v, want only automation-visible repository", statuses) + } +} + +func TestPersonalSearchPaginatesAfterAutomationVisibilityFiltering(t *testing.T) { + personalPRs := make([]*PR, 100, 101) + personalIssues := make([]*Issue, 100, 101) + for i := range 100 { + personalPRs[i] = &PR{RepoOwner: "personal-only", RepoName: "repo", Number: i + 1} + personalIssues[i] = &Issue{RepoOwner: "personal-only", RepoName: "repo", Number: i + 1} + } + personalPRs = append(personalPRs, &PR{RepoOwner: "acme", RepoName: "allowed", Number: 101}) + personalIssues = append(personalIssues, &Issue{RepoOwner: "acme", RepoName: "allowed", Number: 101}) + service := personalBoundaryTestServiceWithResults(t, personalPRs, personalIssues, true) + ctx := context.Background() + + prs, err := service.SearchUserPRsPagedForWorkspaceUser( + ctx, "workspace-1", "user-1", "", "", 1, 1, + ) + if err != nil || len(prs.PRs) != 1 || prs.TotalCount != 1 || prs.PRs[0].Number != 101 { + t.Fatalf("paged PR search = %+v, %v", prs, err) + } + issues, err := service.SearchUserIssuesPagedForWorkspaceUser( + ctx, "workspace-1", "user-1", "", "", 1, 1, + ) + if err != nil || len(issues.Issues) != 1 || issues.TotalCount != 1 || issues.Issues[0].Number != 101 { + t.Fatalf("paged issue search = %+v, %v", issues, err) + } +} + +func personalBoundaryTestService( + t *testing.T, + automationRepos, personalRepos map[string]GitHubRepo, +) *Service { + return personalBoundaryTestServiceWithClients(t, automationRepos, personalRepos, []*PR{ + {RepoOwner: "acme", RepoName: "allowed", Number: 1}, + {RepoOwner: "personal-only", RepoName: "repo", Number: 2}, + }, []*Issue{ + {RepoOwner: "acme", RepoName: "allowed", Number: 1}, + {RepoOwner: "personal-only", RepoName: "repo", Number: 2}, + }, false) +} + +func personalBoundaryTestServiceWithResults( + t *testing.T, prs []*PR, issues []*Issue, paginate bool, +) *Service { + return personalBoundaryTestServiceWithClients(t, + map[string]GitHubRepo{"acme/allowed": testGitHubRepo("acme", "allowed")}, + map[string]GitHubRepo{ + "acme/allowed": testGitHubRepo("acme", "allowed"), + "personal-only/repo": testGitHubRepo("personal-only", "repo"), + }, prs, issues, paginate, + ) +} + +func personalBoundaryTestServiceWithClients( + t *testing.T, + automationRepos, personalRepos map[string]GitHubRepo, + prs []*PR, + issues []*Issue, + paginate bool, +) *Service { + t.Helper() + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "workspace-1") + if err := store.UpsertWorkspaceSettings(context.Background(), &WorkspaceSettings{ + WorkspaceID: "workspace-1", RepoScopeMode: RepoScopeModeAll, + }); err != nil { + t.Fatal(err) + } + installationID := int64(42) + connections := &fakeConnectionReader{ + workspaces: map[string]*WorkspaceConnection{ + "workspace-1": { + WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, AppRegistrationID: "registration-test", + Status: ConnectionStatusActive, CredentialGeneration: 1, + }, + }, + users: map[string]*UserConnection{ + "workspace-1:user-1": { + WorkspaceID: "workspace-1", UserID: "user-1", + AppRegistrationID: "registration-test", Login: "octocat", + Status: ConnectionStatusActive, CredentialGeneration: 1, + }, + }, + } + automationClient := repositoryVisibilityClient{Client: NewMockClient(), repos: automationRepos} + personalClient := repositoryVisibilityClient{ + Client: NewMockClient(), repos: personalRepos, prs: prs, issues: issues, paginate: paginate, + } + resolver := NewCredentialResolver(connections, nil) + resolver.SetAutomationProvider(testAutomationCredentialProvider{client: automationClient}) + resolver.SetUserProvider(fixedUserCredentialProvider{client: personalClient}) + service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + service.resolver = resolver + return service +} + +func testGitHubRepo(owner, name string) GitHubRepo { + return GitHubRepo{FullName: owner + "/" + name, Owner: owner, Name: name} +} diff --git a/apps/backend/internal/github/service_task_events.go b/apps/backend/internal/github/service_task_events.go index c1142d4818..549d6a339b 100644 --- a/apps/backend/internal/github/service_task_events.go +++ b/apps/backend/internal/github/service_task_events.go @@ -2,17 +2,20 @@ package github import ( "context" + "fmt" + "strings" "go.uber.org/zap" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" + taskmodels "github.com/kandev/kandev/internal/task/models" ) -// subscribeTaskEvents wires the github service to task archive/delete events so -// PR watches are pruned proactively. The ListActivePRWatches query already hides -// archived-task watches from the poller, but pruning keeps the table bounded. -// Subscriptions are tracked so they can be released via unsubscribeTaskEvents. +// subscribeTaskEvents wires task/workspace lifecycle cleanup. PR watches are +// pruned proactively, while task, session, and workspace credential leases are +// revoked as soon as their owning runtime scope terminates. Subscriptions are +// tracked so they can be released via unsubscribeTaskEvents. func (s *Service) subscribeTaskEvents() { if s.eventBus == nil { return @@ -27,6 +30,16 @@ func (s *Service) subscribeTaskEvents() { } else { s.taskEventSubs = append(s.taskEventSubs, sub) } + if sub, err := s.eventBus.Subscribe(events.WorkspaceDeleted, s.handleWorkspaceDeleted); err != nil { + s.logger.Error("failed to subscribe to workspace.deleted events", zap.Error(err)) + } else { + s.taskEventSubs = append(s.taskEventSubs, sub) + } + if sub, err := s.eventBus.Subscribe(events.TaskSessionStateChanged, s.handleTaskSessionStateChanged); err != nil { + s.logger.Error("failed to subscribe to task session state events", zap.Error(err)) + } else { + s.taskEventSubs = append(s.taskEventSubs, sub) + } } // unsubscribeTaskEvents releases the subscriptions created in subscribeTaskEvents. @@ -48,6 +61,7 @@ func (s *Service) handleTaskUpdated(ctx context.Context, event *bus.Event) error if taskID == "" || !archived { return nil } + s.revokeCredentialTask(taskID) s.pruneWatchesForTask(ctx, taskID, "archived") return nil } @@ -58,10 +72,83 @@ func (s *Service) handleTaskDeleted(ctx context.Context, event *bus.Event) error if taskID == "" { return nil } + s.revokeCredentialTask(taskID) s.pruneWatchesForTask(ctx, taskID, "deleted") return nil } +func (s *Service) handleTaskSessionStateChanged(_ context.Context, event *bus.Event) error { + sessionID, terminal := terminalSessionIDFrom(event) + if sessionID == "" || !terminal { + return nil + } + s.mu.Lock() + broker := s.credentialBroker + s.mu.Unlock() + if broker != nil { + broker.RevokeSession(sessionID) + } + return nil +} + +type connectionSecretIDLister interface { + ListIDs(context.Context) ([]string, error) +} + +// handleWorkspaceDeleted removes encrypted GitHub credentials after the +// workspace-owned metadata rows have been deleted by the database cascade. +// Secret IDs are namespaced, so future multi-user connections are covered +// without reading deleted user-connection rows. +func (s *Service) handleWorkspaceDeleted(ctx context.Context, event *bus.Event) error { + workspaceID := workspaceIDFromEvent(event) + if workspaceID == "" { + return nil + } + s.mu.Lock() + broker := s.credentialBroker + s.mu.Unlock() + if broker != nil { + broker.RevokeWorkspace(workspaceID) + } + if s.connectionSecrets == nil { + return nil + } + keys := []string{WorkspacePATSecretKey(workspaceID)} + if lister, ok := s.connectionSecrets.(connectionSecretIDLister); ok { + ids, err := lister.ListIDs(ctx) + if err != nil { + return fmt.Errorf("list GitHub connection secrets: %w", err) + } + userPrefix := "github:user:" + workspaceID + ":" + for _, id := range ids { + if strings.HasPrefix(id, userPrefix) { + keys = append(keys, id) + } + } + } else { + keys = append(keys, + UserAccessTokenSecretKey(workspaceID, DefaultUserID), + UserRefreshTokenSecretKey(workspaceID, DefaultUserID), + ) + } + for _, key := range keys { + if err := deleteOptionalSecret(ctx, s.connectionSecrets, key); err != nil { + return fmt.Errorf("delete GitHub connection secret %q: %w", key, err) + } + } + s.invalidateWorkspaceCredential(workspaceID) + return nil +} + +func (s *Service) revokeCredentialTask(taskID string) { + s.mu.Lock() + broker := s.credentialBroker + s.mu.Unlock() + if broker != nil { + broker.RevokeTask(taskID) + } +} + func (s *Service) pruneWatchesForTask(ctx context.Context, taskID, reason string) { n, err := s.store.DeletePRWatchesByTaskID(ctx, taskID) if err != nil { @@ -96,3 +183,35 @@ func taskIDAndArchivedFrom(event *bus.Event) (taskID string, archived bool) { archivedStr, _ := data["archived_at"].(string) return id, archivedStr != "" } + +func workspaceIDFromEvent(event *bus.Event) string { + if event == nil { + return "" + } + data, ok := event.Data.(map[string]interface{}) + if !ok { + return "" + } + workspaceID, _ := data["id"].(string) + return strings.TrimSpace(workspaceID) +} + +func terminalSessionIDFrom(event *bus.Event) (string, bool) { + if event == nil { + return "", false + } + data, ok := event.Data.(map[string]interface{}) + if !ok { + return "", false + } + sessionID, _ := data["session_id"].(string) + state, _ := data["new_state"].(string) + switch taskmodels.TaskSessionState(strings.ToUpper(strings.TrimSpace(state))) { + case taskmodels.TaskSessionStateCompleted, + taskmodels.TaskSessionStateFailed, + taskmodels.TaskSessionStateCancelled: + return strings.TrimSpace(sessionID), true + default: + return strings.TrimSpace(sessionID), false + } +} diff --git a/apps/backend/internal/github/service_task_events_test.go b/apps/backend/internal/github/service_task_events_test.go index a5456968ad..ceae79e7fb 100644 --- a/apps/backend/internal/github/service_task_events_test.go +++ b/apps/backend/internal/github/service_task_events_test.go @@ -2,10 +2,13 @@ package github import ( "context" + "crypto/sha256" "testing" + "time" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" + taskmodels "github.com/kandev/kandev/internal/task/models" ) func TestListActivePRWatches_ExcludesArchivedTasks(t *testing.T) { @@ -107,6 +110,22 @@ func TestHandleTaskDeleted_DeletesWatches(t *testing.T) { } } +func TestHandleTaskDeletedRevokesCredentialLeases(t *testing.T) { + _, svc, _, _ := setupPollerTest(t) + broker := revocationTestBroker("workspace-1", "t1", "s1") + svc.SetCredentialBroker(broker) + + event := bus.NewEvent(events.TaskDeleted, "task-service", map[string]interface{}{ + "task_id": "t1", + }) + if err := svc.handleTaskDeleted(context.Background(), event); err != nil { + t.Fatalf("handleTaskDeleted: %v", err) + } + if got := len(broker.leases); got != 0 { + t.Fatalf("lease records = %d, want 0", got) + } +} + func TestHandleTaskEvents_MalformedPayloadIsNoop(t *testing.T) { _, svc, _, store := setupPollerTest(t) ctx := context.Background() @@ -131,6 +150,66 @@ func TestHandleTaskEvents_MalformedPayloadIsNoop(t *testing.T) { } } +func TestHandleWorkspaceDeletedRemovesOnlyOwnedConnectionSecrets(t *testing.T) { + svc, secrets := newWorkspaceConnectionService(t, "octocat") + secrets.values[WorkspacePATSecretKey("ws-1")] = "pat" + secrets.values[UserAccessTokenSecretKey("ws-1", "user-1")] = "access" + secrets.values[UserRefreshTokenSecretKey("ws-1", "user-1")] = "refresh" + secrets.values[WorkspacePATSecretKey("ws-2")] = "other" + + event := bus.NewEvent(events.WorkspaceDeleted, "task-service", map[string]interface{}{"id": "ws-1"}) + if err := svc.handleWorkspaceDeleted(context.Background(), event); err != nil { + t.Fatalf("handleWorkspaceDeleted: %v", err) + } + for _, id := range []string{ + WorkspacePATSecretKey("ws-1"), + UserAccessTokenSecretKey("ws-1", "user-1"), + UserRefreshTokenSecretKey("ws-1", "user-1"), + } { + if _, ok := secrets.values[id]; ok { + t.Fatalf("workspace-owned secret %q was not deleted", id) + } + } + if got := secrets.values[WorkspacePATSecretKey("ws-2")]; got != "other" { + t.Fatalf("unrelated workspace secret changed: %q", got) + } +} + +func TestHandleWorkspaceDeletedRevokesCredentialLeasesWithoutSecretStore(t *testing.T) { + _, svc, _, _ := setupPollerTest(t) + broker := revocationTestBroker("workspace-1", "t1", "s1") + svc.SetCredentialBroker(broker) + svc.connectionSecrets = nil + + event := bus.NewEvent(events.WorkspaceDeleted, "task-service", map[string]interface{}{"id": "workspace-1"}) + if err := svc.handleWorkspaceDeleted(context.Background(), event); err != nil { + t.Fatalf("handleWorkspaceDeleted: %v", err) + } + if got := len(broker.leases); got != 0 { + t.Fatalf("lease records = %d, want 0", got) + } +} + +func TestSubscribeTaskEventsTerminalSessionRevokesCredentialLease(t *testing.T) { + _, svc, _, _ := setupPollerTest(t) + broker := revocationTestBroker("workspace-1", "t1", "s1") + svc.SetCredentialBroker(broker) + svc.subscribeTaskEvents() + t.Cleanup(svc.unsubscribeTaskEvents) + + event := bus.NewEvent(events.TaskSessionStateChanged, "orchestrator", map[string]interface{}{ + "task_id": "t1", + "session_id": "s1", + "new_state": string(taskmodels.TaskSessionStateCompleted), + }) + if err := svc.eventBus.Publish(context.Background(), events.TaskSessionStateChanged, event); err != nil { + t.Fatalf("publish: %v", err) + } + if got := len(broker.leases); got != 0 { + t.Fatalf("lease records = %d, want 0", got) + } +} + func TestSubscribeTaskEvents_EndToEnd(t *testing.T) { _, svc, _, store := setupPollerTest(t) ctx := context.Background() @@ -169,3 +248,18 @@ func mustCreateWatch(t *testing.T, store *Store, sessionID, taskID string) { t.Fatalf("create PR watch: %v", err) } } + +func revocationTestBroker(workspaceID, taskID, sessionID string) *CredentialBroker { + hash := sha256.Sum256([]byte(workspaceID + taskID + sessionID)) + return &CredentialBroker{ + leases: map[[sha256.Size]byte]credentialLeaseRecord{ + hash: { + WorkspaceID: workspaceID, + TaskID: taskID, + SessionID: sessionID, + ExpiresAt: time.Now().Add(time.Hour), + }, + }, + now: time.Now, + } +} diff --git a/apps/backend/internal/github/service_task_issue.go b/apps/backend/internal/github/service_task_issue.go index ab9132d2cd..94b7c15672 100644 --- a/apps/backend/internal/github/service_task_issue.go +++ b/apps/backend/internal/github/service_task_issue.go @@ -64,6 +64,42 @@ func (s *Service) LinkTaskIssue(ctx context.Context, taskID string, req LinkTask if s.client == nil { return nil, ErrNoClient } + return s.linkTaskIssueWithClient(ctx, s.client, taskID, req) +} + +// LinkTaskIssueForUser derives workspace ownership from the task before +// resolving a personal-read credential. A task without ownership fails closed. +func (s *Service) LinkTaskIssueForUser( + ctx context.Context, userID, taskID string, req LinkTaskIssueRequest, +) (*TaskIssueLinkResponse, error) { + store := s.getTaskIssueStore() + if store == nil { + return nil, errStoreUnavailable + } + task, err := store.GetTask(ctx, taskID) + if err != nil { + return nil, err + } + if task == nil || strings.TrimSpace(task.WorkspaceID) == "" { + return nil, ErrGitHubWorkspaceRequired + } + owner, repo, number, err := resolveIssueReference(req) + if err != nil { + return nil, err + } + if err := s.ensureRepositoryInWorkspaceScope(ctx, task.WorkspaceID, owner, repo); err != nil { + return nil, err + } + resolved, err := s.resolvePersonalReadClient(ctx, task.WorkspaceID, userID, owner, repo) + if err != nil { + return nil, err + } + return s.linkTaskIssueResolved(ctx, resolved.Client, store, task, taskID, owner, repo, number) +} + +func (s *Service) linkTaskIssueWithClient( + ctx context.Context, client Client, taskID string, req LinkTaskIssueRequest, +) (*TaskIssueLinkResponse, error) { store := s.getTaskIssueStore() if store == nil { return nil, errStoreUnavailable @@ -76,7 +112,20 @@ func (s *Service) LinkTaskIssue(ctx context.Context, taskID string, req LinkTask if err != nil { return nil, err } - issue, err := s.client.GetIssue(ctx, owner, repo, number) + return s.linkTaskIssueResolved(ctx, client, store, task, taskID, owner, repo, number) +} + +func (s *Service) linkTaskIssueResolved( + ctx context.Context, + client Client, + store TaskIssueStore, + task *taskmodels.Task, + taskID string, + owner string, + repo string, + number int, +) (*TaskIssueLinkResponse, error) { + issue, err := client.GetIssue(ctx, owner, repo, number) if err != nil { return nil, err } diff --git a/apps/backend/internal/github/service_tokens.go b/apps/backend/internal/github/service_tokens.go index affa0829de..f334b8af1c 100644 --- a/apps/backend/internal/github/service_tokens.go +++ b/apps/backend/internal/github/service_tokens.go @@ -39,10 +39,14 @@ func (s *Service) ExhaustedRateLimits() []ExhaustedRateLimit { // returned by GetStatus and the rate-limit WS notification. Returns nil // when no buckets are known yet so the field stays omitted. func (s *Service) rateLimitInfo() *GitHubRateLimitInfo { - if s.rateTracker == nil { + return rateLimitInfoForTracker(s.rateTracker) +} + +func rateLimitInfoForTracker(tracker *RateTracker) *GitHubRateLimitInfo { + if tracker == nil { return nil } - all := s.rateTracker.All() + all := tracker.All() if len(all) == 0 { return nil } diff --git a/apps/backend/internal/github/service_watch_reset.go b/apps/backend/internal/github/service_watch_reset.go index dd3cd69b10..0262a4aa60 100644 --- a/apps/backend/internal/github/service_watch_reset.go +++ b/apps/backend/internal/github/service_watch_reset.go @@ -33,6 +33,10 @@ func (s *Service) PreviewResetIssueWatch(ctx context.Context, watchID string) (i if s.store == nil { return 0, errStoreUnavailable } + watch, err := s.GetIssueWatch(ctx, watchID) + if err != nil || watch == nil { + return 0, err + } return watchreset.Preview(ctx, &githubIssueWatchResetter{store: s.store, watchID: watchID}) } @@ -44,6 +48,10 @@ func (s *Service) ResetIssueWatch(ctx context.Context, watchID string) (int, err if s.store == nil { return 0, errStoreUnavailable } + watch, err := s.GetIssueWatch(ctx, watchID) + if err != nil || watch == nil { + return 0, err + } s.mu.Lock() td := s.cascadeTaskDeleter s.mu.Unlock() diff --git a/apps/backend/internal/github/service_workspace_authorization_test.go b/apps/backend/internal/github/service_workspace_authorization_test.go new file mode 100644 index 0000000000..63645b280f --- /dev/null +++ b/apps/backend/internal/github/service_workspace_authorization_test.go @@ -0,0 +1,202 @@ +package github + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestReviewWatchOperationsAuthorizeWorkspaceAtServiceBoundary(t *testing.T) { + store := newTestStore(t) + svc := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + watch := &ReviewWatch{WorkspaceID: "victim-workspace", Prompt: "private prompt"} + if err := store.CreateReviewWatch(context.Background(), watch); err != nil { + t.Fatal(err) + } + denied := errors.New("workspace not found") + svc.SetWorkspaceAuthorizer(func(_ context.Context, workspaceID string) error { + if workspaceID != "victim-workspace" { + t.Fatalf("workspaceID = %q, want victim workspace", workspaceID) + } + return denied + }) + + if _, err := svc.ListReviewWatches(context.Background(), "victim-workspace"); !errors.Is(err, denied) { + t.Fatalf("ListReviewWatches() error = %v, want authorization denial", err) + } + if _, err := svc.GetReviewWatch(context.Background(), watch.ID); !errors.Is(err, denied) { + t.Fatalf("GetReviewWatch() error = %v, want authorization denial", err) + } + if err := svc.UpdateReviewWatch(context.Background(), watch.ID, &UpdateReviewWatchRequest{}); !errors.Is(err, denied) { + t.Fatalf("UpdateReviewWatch() error = %v, want authorization denial", err) + } + if err := svc.DeleteReviewWatch(context.Background(), watch.ID); !errors.Is(err, denied) { + t.Fatalf("DeleteReviewWatch() error = %v, want authorization denial", err) + } +} + +func TestIssueAndPRWatchOperationsAuthorizeStoredWorkspace(t *testing.T) { + store := newTestStore(t) + svc := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + ctx := context.Background() + issueWatch := &IssueWatch{WorkspaceID: "victim-workspace"} + if err := store.CreateIssueWatch(ctx, issueWatch); err != nil { + t.Fatal(err) + } + prWatch := &PRWatch{WorkspaceID: "victim-workspace", SessionID: "session-1", TaskID: "task-1", Owner: "acme", Repo: "private", Branch: "main"} + if err := store.CreatePRWatch(ctx, prWatch); err != nil { + t.Fatal(err) + } + denied := errors.New("workspace not found") + svc.SetWorkspaceAuthorizer(func(_ context.Context, workspaceID string) error { + if workspaceID != "victim-workspace" { + t.Fatalf("workspaceID = %q, want victim workspace", workspaceID) + } + return denied + }) + + if _, err := svc.GetIssueWatch(ctx, issueWatch.ID); !errors.Is(err, denied) { + t.Fatalf("GetIssueWatch() error = %v, want authorization denial", err) + } + if _, err := svc.ListIssueWatches(ctx, issueWatch.WorkspaceID); !errors.Is(err, denied) { + t.Fatalf("ListIssueWatches() error = %v, want authorization denial", err) + } + if err := svc.UpdateIssueWatch(ctx, issueWatch.ID, &UpdateIssueWatchRequest{}); !errors.Is(err, denied) { + t.Fatalf("UpdateIssueWatch() error = %v, want authorization denial", err) + } + if err := svc.DeleteIssueWatch(ctx, issueWatch.ID); !errors.Is(err, denied) { + t.Fatalf("DeleteIssueWatch() error = %v, want authorization denial", err) + } + if err := svc.DeletePRWatch(ctx, prWatch.ID); !errors.Is(err, denied) { + t.Fatalf("DeletePRWatch() error = %v, want authorization denial", err) + } +} + +func TestWorkspaceSettingsAndActionPresetsAuthorizeBodyWorkspace(t *testing.T) { + svc, _, _ := setupSyncTest(t) + denied := errors.New("workspace not found") + svc.SetWorkspaceAuthorizer(func(_ context.Context, workspaceID string) error { + if workspaceID != "victim-workspace" { + t.Fatalf("workspaceID = %q, want victim workspace", workspaceID) + } + return denied + }) + ctx := context.Background() + + if _, err := svc.GetWorkspaceSettings(ctx, "victim-workspace"); !errors.Is(err, denied) { + t.Fatalf("GetWorkspaceSettings() error = %v, want authorization denial", err) + } + if _, err := svc.UpdateWorkspaceSettings(ctx, &UpdateWorkspaceSettingsRequest{WorkspaceID: "victim-workspace"}); !errors.Is(err, denied) { + t.Fatalf("UpdateWorkspaceSettings() error = %v, want authorization denial", err) + } + if _, err := svc.GetActionPresets(ctx, "victim-workspace"); !errors.Is(err, denied) { + t.Fatalf("GetActionPresets() error = %v, want authorization denial", err) + } + if _, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{WorkspaceID: "victim-workspace"}); !errors.Is(err, denied) { + t.Fatalf("UpdateActionPresets() error = %v, want authorization denial", err) + } +} + +func TestCopyWorkspaceSettingsAuthorizesSourceAndTarget(t *testing.T) { + t.Run("source", func(t *testing.T) { + svc := newCopyTestService(t) + denied := errors.New("source denied") + svc.SetWorkspaceAuthorizer(func(_ context.Context, workspaceID string) error { + if workspaceID == "source" { + return denied + } + return nil + }) + if _, err := svc.CopyWorkspaceSettingsToWorkspace(context.Background(), "source", "target"); !errors.Is(err, denied) { + t.Fatalf("copy error = %v, want source denial", err) + } + }) + t.Run("target", func(t *testing.T) { + svc := newCopyTestService(t) + denied := errors.New("target denied") + svc.SetWorkspaceAuthorizer(func(_ context.Context, workspaceID string) error { + if workspaceID == "target" { + return denied + } + return nil + }) + if _, err := svc.CopyWorkspaceSettingsToWorkspace(context.Background(), "source", "target"); !errors.Is(err, denied) { + t.Fatalf("copy error = %v, want target denial", err) + } + }) + t.Run("both", func(t *testing.T) { + svc := newCopyTestService(t) + var authorized []string + svc.SetWorkspaceAuthorizer(func(_ context.Context, workspaceID string) error { + authorized = append(authorized, workspaceID) + return nil + }) + if _, err := svc.CopyWorkspaceSettingsToWorkspace(context.Background(), "source", "target"); err != nil { + t.Fatal(err) + } + if len(authorized) != 2 || authorized[0] != "source" || authorized[1] != "target" { + t.Fatalf("authorized = %#v, want source then target", authorized) + } + }) +} + +func TestGetPRForWorkspaceWithoutConnectionUsesAnonymousClient(t *testing.T) { + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":1,"title":"anonymous","state":"open","user":{"login":"octo"},"head":{"ref":"feature","sha":"abc"},"base":{"ref":"main"}}`)) + })) + t.Cleanup(api.Close) + oldBase := anonymousAPIBase + anonymousAPIBase = api.URL + t.Cleanup(func() { anonymousAPIBase = oldBase }) + + svc := NewService(&stubClient{getPRFunc: func(context.Context, string, string, int) (*PR, error) { + return &PR{Title: "ambient credential"}, nil + }}, AuthMethodPAT, nil, nil, nil, testLogger(t)) + pr, err := svc.GetPRForWorkspace(context.Background(), "workspace-without-connection", "user-1", "acme", "private", 1) + if err != nil { + t.Fatal(err) + } + if pr.Title != "anonymous" { + t.Fatalf("PR title = %q, want anonymous lookup without ambient credential", pr.Title) + } +} + +func TestListAllReviewWatchesRetainsIdentitylessInternalUse(t *testing.T) { + store := newTestStore(t) + svc := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + for _, workspaceID := range []string{"workspace-a", "workspace-b"} { + if err := store.CreateReviewWatch(context.Background(), &ReviewWatch{WorkspaceID: workspaceID}); err != nil { + t.Fatal(err) + } + } + watches, err := svc.ListAllReviewWatches(context.Background()) + if err != nil || len(watches) != 2 { + t.Fatalf("identityless internal list = %#v, %v; want two watches", watches, err) + } +} + +func TestIssueAndPRAllWorkspaceListsRetainIdentitylessInternalUse(t *testing.T) { + store := newTestStore(t) + svc := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) + ctx := context.Background() + for _, workspaceID := range []string{"workspace-a", "workspace-b"} { + if err := store.CreateIssueWatch(ctx, &IssueWatch{WorkspaceID: workspaceID}); err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec(`INSERT INTO tasks (id, workspace_id) VALUES (?, ?)`, "task-"+workspaceID, workspaceID); err != nil { + t.Fatal(err) + } + if err := store.CreatePRWatch(ctx, &PRWatch{WorkspaceID: workspaceID, SessionID: "session-" + workspaceID, TaskID: "task-" + workspaceID, Owner: "acme", Repo: "repo", Branch: "main"}); err != nil { + t.Fatal(err) + } + } + if watches, err := svc.ListAllIssueWatches(ctx); err != nil || len(watches) != 2 { + t.Fatalf("identityless issue list = %#v, %v; want two watches", watches, err) + } + if watches, err := svc.ListActivePRWatches(ctx); err != nil || len(watches) != 2 { + t.Fatalf("identityless PR list = %#v, %v; want two watches", watches, err) + } +} diff --git a/apps/backend/internal/github/store.go b/apps/backend/internal/github/store.go index d737d02bd9..ac195a8938 100644 --- a/apps/backend/internal/github/store.go +++ b/apps/backend/internal/github/store.go @@ -8,16 +8,27 @@ import ( "fmt" "os" "strings" + "sync" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" + + dbutil "github.com/kandev/kandev/internal/db" ) // Store provides SQLite persistence for GitHub integration data. type Store struct { - db *sqlx.DB // writer - ro *sqlx.DB // reader + db *sqlx.DB // writer + ro *sqlx.DB // reader + deploymentAppPersistenceMu sync.Mutex + appLifecycleLocksMu sync.Mutex + appLifecycleLocks map[string]*appRegistrationLifecycleLock +} + +type appRegistrationLifecycleLock struct { + mu sync.Mutex + refs int } type taskIssueMetadataRow struct { @@ -28,13 +39,40 @@ type taskIssueMetadataRow struct { // NewStore creates a new GitHub store and initializes the schema. func NewStore(writer, reader *sqlx.DB) (*Store, error) { - s := &Store{db: writer, ro: reader} - if err := s.initSchema(); err != nil { + s := &Store{ + db: writer, ro: reader, + appLifecycleLocks: make(map[string]*appRegistrationLifecycleLock), + } + legacyUpgrade := s.tableExists("github_workspace_settings") && + !s.tableExists("github_workspace_connections") + if err := s.initSchema(legacyUpgrade); err != nil { return nil, fmt.Errorf("github schema init: %w", err) } return s, nil } +func (s *Store) lockAppRegistrationLifecycle(registrationID string) func() { + s.appLifecycleLocksMu.Lock() + lock := s.appLifecycleLocks[registrationID] + if lock == nil { + lock = &appRegistrationLifecycleLock{} + s.appLifecycleLocks[registrationID] = lock + } + lock.refs++ + s.appLifecycleLocksMu.Unlock() + + lock.mu.Lock() + return func() { + lock.mu.Unlock() + s.appLifecycleLocksMu.Lock() + lock.refs-- + if lock.refs == 0 { + delete(s.appLifecycleLocks, registrationID) + } + s.appLifecycleLocksMu.Unlock() + } +} + // createTablesSQL holds the DDL for all GitHub integration tables. // // Multi-repo: both `github_pr_watches` and `github_task_prs` carry a @@ -46,6 +84,7 @@ func NewStore(writer, reader *sqlx.DB) (*Store, error) { const createTablesSQL = ` CREATE TABLE IF NOT EXISTS github_pr_watches ( id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', session_id TEXT NOT NULL, task_id TEXT NOT NULL, repository_id TEXT NOT NULL DEFAULT '', @@ -64,6 +103,7 @@ const createTablesSQL = ` CREATE TABLE IF NOT EXISTS github_task_prs ( id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', task_id TEXT NOT NULL, repository_id TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL, @@ -106,6 +146,7 @@ const createTablesSQL = ` prompt TEXT DEFAULT '', review_scope TEXT NOT NULL DEFAULT 'user_and_teams', custom_query TEXT NOT NULL DEFAULT '', + target_login TEXT NOT NULL DEFAULT '', enabled BOOLEAN DEFAULT 1, poll_interval_seconds INTEGER DEFAULT 300, cleanup_policy TEXT NOT NULL DEFAULT 'auto', @@ -179,6 +220,136 @@ const createTablesSQL = ` updated_at DATETIME NOT NULL ); + CREATE TABLE IF NOT EXISTS github_workspace_connections ( + workspace_id TEXT PRIMARY KEY, + source TEXT NOT NULL CHECK (source IN ('legacy_shared', 'pat', 'gh_cli', 'github_app_installation')), + github_host TEXT NOT NULL CHECK (github_host <> ''), + login TEXT, + installation_id BIGINT, + installation_account_login TEXT, + installation_account_type TEXT CHECK ( + installation_account_type IS NULL OR installation_account_type IN ('User', 'Organization') + ), + app_registration_id TEXT, + status TEXT NOT NULL CHECK (status IN ('active', 'invalid', 'suspended', 'revoked')), + credential_generation BIGINT NOT NULL DEFAULT 1 CHECK (credential_generation > 0), + last_error TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CHECK ( + (source = 'legacy_shared' AND login IS NULL AND installation_id IS NULL AND app_registration_id IS NULL) OR + (source IN ('pat', 'gh_cli') AND login IS NOT NULL AND login <> '' AND installation_id IS NULL AND app_registration_id IS NULL) OR + (source = 'github_app_installation' AND installation_id IS NOT NULL AND installation_id > 0 AND + installation_account_login IS NOT NULL AND installation_account_login <> '' AND + installation_account_type IS NOT NULL AND app_registration_id IS NOT NULL) + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + FOREIGN KEY (app_registration_id) REFERENCES github_app_registrations(id) ON DELETE RESTRICT + ); + + CREATE TABLE IF NOT EXISTS github_user_connections ( + workspace_id TEXT NOT NULL, + user_id TEXT NOT NULL, + app_registration_id TEXT NOT NULL, + github_user_id BIGINT NOT NULL CHECK (github_user_id > 0), + login TEXT NOT NULL CHECK (login <> ''), + status TEXT NOT NULL CHECK (status IN ('active', 'invalid', 'revoked')), + access_expires_at TIMESTAMP NOT NULL, + refresh_expires_at TIMESTAMP, + credential_generation BIGINT NOT NULL DEFAULT 1 CHECK (credential_generation > 0), + last_error TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (workspace_id, user_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + FOREIGN KEY (app_registration_id) REFERENCES github_app_registrations(id) ON DELETE RESTRICT + ); + + CREATE TRIGGER IF NOT EXISTS github_user_connections_registration_insert + BEFORE INSERT ON github_user_connections + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM github_workspace_connections workspace + WHERE workspace.workspace_id = NEW.workspace_id + AND workspace.source = 'github_app_installation' + AND workspace.app_registration_id = NEW.app_registration_id + ) THEN RAISE(ABORT, 'personal GitHub App registration must match workspace') END; + END; + + CREATE TRIGGER IF NOT EXISTS github_user_connections_registration_update + BEFORE UPDATE OF workspace_id, app_registration_id ON github_user_connections + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM github_workspace_connections workspace + WHERE workspace.workspace_id = NEW.workspace_id + AND workspace.source = 'github_app_installation' + AND workspace.app_registration_id = NEW.app_registration_id + ) THEN RAISE(ABORT, 'personal GitHub App registration must match workspace') END; + END; + + CREATE TRIGGER IF NOT EXISTS github_workspace_connections_registration_update + BEFORE UPDATE OF source, app_registration_id ON github_workspace_connections + BEGIN + SELECT CASE WHEN EXISTS ( + SELECT 1 FROM github_user_connections personal + WHERE personal.workspace_id = OLD.workspace_id + AND ( + NEW.source <> 'github_app_installation' OR + NEW.app_registration_id IS NULL OR + personal.app_registration_id <> NEW.app_registration_id + ) + ) THEN RAISE(ABORT, 'workspace GitHub App registration must match personal connections') END; + END; + + CREATE TRIGGER IF NOT EXISTS github_workspace_connections_registration_delete + BEFORE DELETE ON github_workspace_connections + BEGIN + SELECT CASE WHEN EXISTS ( + SELECT 1 FROM github_user_connections personal + WHERE personal.workspace_id = OLD.workspace_id + ) THEN RAISE(ABORT, 'workspace GitHub App connection still has personal connections') END; + END; + + CREATE TABLE IF NOT EXISTS github_user_connection_versions ( + workspace_id TEXT NOT NULL, + user_id TEXT NOT NULL, + credential_generation BIGINT NOT NULL DEFAULT 0 CHECK (credential_generation >= 0), + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (workspace_id, user_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS github_auth_flows ( + state_hash TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + user_id TEXT NOT NULL, + app_registration_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('app_installation', 'personal')), + pkce_verifier TEXT NOT NULL DEFAULT '', + expected_workspace_source TEXT NOT NULL DEFAULT '', + expected_workspace_generation BIGINT NOT NULL DEFAULT 0, + expected_installation_id BIGINT, + expected_workspace_app_registration_id TEXT, + expected_personal_generation BIGINT NOT NULL DEFAULT 0, + expires_at TIMESTAMP NOT NULL, + consumed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + FOREIGN KEY (app_registration_id) REFERENCES github_app_registrations(id) ON DELETE RESTRICT + ); + + CREATE TABLE IF NOT EXISTS github_webhook_deliveries ( + app_registration_id TEXT NOT NULL, + delivery_id TEXT NOT NULL, + event TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('received', 'processed', 'ignored', 'failed')), + result TEXT NOT NULL DEFAULT '', + received_at TIMESTAMP NOT NULL, + processed_at TIMESTAMP, + PRIMARY KEY (app_registration_id, delivery_id), + FOREIGN KEY (app_registration_id) REFERENCES github_app_registrations(id) ON DELETE RESTRICT + ); + CREATE TABLE IF NOT EXISTS github_task_ci_options ( task_id TEXT PRIMARY KEY, auto_fix_enabled BOOLEAN NOT NULL DEFAULT 0, @@ -207,10 +378,92 @@ const createTablesSQL = ` ); ` -func (s *Store) initSchema() error { - if _, err := s.db.Exec(createTablesSQL); err != nil { +const appRegistrationTablesSQL = ` + CREATE TABLE IF NOT EXISTS github_app_registrations ( + id TEXT PRIMARY KEY CHECK (id <> ''), + source TEXT NOT NULL CHECK (source IN ('managed', 'imported')), + display_name TEXT NOT NULL CHECK (display_name <> ''), + github_host TEXT NOT NULL CHECK (github_host <> ''), + app_id BIGINT NOT NULL CHECK (app_id > 0), + client_id TEXT NOT NULL CHECK (client_id <> ''), + slug TEXT NOT NULL CHECK (slug <> ''), + owner_login TEXT NOT NULL CHECK (owner_login <> ''), + owner_type TEXT NOT NULL CHECK (owner_type IN ('User', 'Organization')), + visibility TEXT NOT NULL CHECK (visibility IN ('private', 'public')), + public_base_url TEXT NOT NULL CHECK (public_base_url <> ''), + created_for_workspace_id TEXT, + credential_generation BIGINT NOT NULL CHECK (credential_generation > 0), + credential_secret_id TEXT NOT NULL CHECK (credential_secret_id <> ''), + status TEXT NOT NULL CHECK (status IN ('active', 'invalid')), + webhook_status TEXT NOT NULL CHECK (webhook_status IN ('unverified', 'verified', 'failing')), + last_webhook_at TIMESTAMP, + last_error TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE (github_host, app_id) + ); + + CREATE TABLE IF NOT EXISTS github_app_registration_flows ( + state_hash TEXT PRIMARY KEY, + registration_id TEXT NOT NULL CHECK (registration_id <> ''), + workspace_id TEXT NOT NULL CHECK (workspace_id <> ''), + user_id TEXT NOT NULL CHECK (user_id <> ''), + owner_type TEXT NOT NULL CHECK (owner_type IN ('User', 'Organization')), + owner_login TEXT NOT NULL CHECK (owner_login <> ''), + display_name TEXT NOT NULL CHECK (display_name <> ''), + visibility TEXT NOT NULL CHECK (visibility IN ('private', 'public')), + public_base_url TEXT NOT NULL CHECK (public_base_url <> ''), + manifest_revision INTEGER NOT NULL CHECK (manifest_revision > 0), + expires_at TIMESTAMP NOT NULL, + consumed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS github_app_import_preparations ( + registration_id TEXT PRIMARY KEY CHECK (registration_id <> ''), + workspace_id TEXT NOT NULL CHECK (workspace_id <> ''), + user_id TEXT NOT NULL CHECK (user_id <> ''), + public_base_url TEXT NOT NULL CHECK (public_base_url <> ''), + expires_at TIMESTAMP NOT NULL, + consumed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); +` + +func (s *Store) initSchema(legacyUpgrade bool) error { + if err := s.initSchemaFoundations(); err != nil { return err } + s.applyIdempotentSchemaColumns() + if err := s.initSchemaUpgrades(); err != nil { + return err + } + if err := s.initSchemaData(legacyUpgrade); err != nil { + return err + } + s.applyIdempotentSchemaIndexes() + return s.ensureWorkspaceOwnershipIndexes() +} + +func (s *Store) initSchemaFoundations() error { + if err := s.resetUnpublishedGitHubAuthSchema(); err != nil { + return err + } + if err := s.initCoreSchema(); err != nil { + return err + } + if err := s.addWorkspaceOwnershipColumns(); err != nil { + return err + } + if err := s.addReviewWatchTargetLogin(); err != nil { + return err + } + return nil +} + +func (s *Store) applyIdempotentSchemaColumns() { // Idempotent migrations for existing databases. _, _ = s.db.Exec(`ALTER TABLE github_pr_watches ADD COLUMN last_review_state TEXT DEFAULT ''`) _, _ = s.db.Exec(`ALTER TABLE github_task_prs ADD COLUMN mergeable_state TEXT NOT NULL DEFAULT ''`) @@ -230,6 +483,9 @@ func (s *Store) initSchema() error { // engaged), 'always' (delete on terminal state), 'never' (manual only). _, _ = s.db.Exec(`ALTER TABLE github_review_watches ADD COLUMN cleanup_policy TEXT NOT NULL DEFAULT 'auto'`) _, _ = s.db.Exec(`ALTER TABLE github_issue_watches ADD COLUMN cleanup_policy TEXT NOT NULL DEFAULT 'auto'`) +} + +func (s *Store) initSchemaUpgrades() error { // Watcher self-heal columns: when the dispatch pipeline detects an // orphaned watcher (e.g. its agent profile has been soft-deleted), it // disables the row and stamps a human-readable cause + timestamp here @@ -245,6 +501,19 @@ func (s *Store) initSchema() error { if err := s.addTaskCIRoundColumns(); err != nil { return err } + if err := s.addGitHubAuthFlowExpectationColumns(); err != nil { + return err + } + if err := s.addAppRegistrationReferenceColumns(); err != nil { + return err + } + return nil +} + +func (s *Store) initSchemaData(legacyUpgrade bool) error { + if err := s.backfillGitHubUserConnectionVersions(); err != nil { + return err + } if err := s.migratePRTablesForMultiRepo(); err != nil { return fmt.Errorf("migrate PR tables for multi-repo: %w", err) } @@ -254,11 +523,250 @@ func (s *Store) initSchema() error { if err := s.backfillPRWatchesRepositoryID(); err != nil { return fmt.Errorf("backfill github_pr_watches.repository_id: %w", err) } + if err := s.backfillGitHubWorkspaceOwnership(); err != nil { + return err + } + if legacyUpgrade { + if err := s.seedLegacyWorkspaceConnections(); err != nil { + return err + } + } + return nil +} + +func (s *Store) applyIdempotentSchemaIndexes() { // pr_number is the 3rd column of UNIQUE(task_id, repository_id, pr_number), // so SQLite can't use that index for the PR-number task search. Add a // dedicated leading-key index so lookups by PR number stay index-backed. _, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_github_task_prs_pr_number ON github_task_prs (pr_number)`) _, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_github_task_ci_pr_state_task ON github_task_ci_pr_state (task_id)`) +} + +func (s *Store) resetUnpublishedGitHubAuthSchema() error { + reset, err := s.unpublishedGitHubAuthSchemaNeedsReset() + if err != nil || !reset { + return err + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + for _, object := range []string{ + "github_user_connections_registration_insert", + "github_user_connections_registration_update", + "github_workspace_connections_registration_update", + "github_workspace_connections_registration_delete", + } { + if _, err := tx.Exec(`DROP TRIGGER IF EXISTS ` + object); err != nil { + return err + } + } + for _, table := range []string{ + "github_webhook_deliveries", + "github_auth_flows", + "github_user_connection_versions", + "github_user_connections", + "github_workspace_connections", + "github_app_import_preparations", + "github_app_registration_flows", + "github_app_registrations", + "github_app_registration_flow_head", + "github_app_registration", + } { + if _, err := tx.Exec(`DROP TABLE IF EXISTS ` + table); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *Store) unpublishedGitHubAuthSchemaNeedsReset() (bool, error) { + for _, singleton := range []string{"github_app_registration", "github_app_registration_flow_head"} { + if s.tableExists(singleton) { + return true, nil + } + } + required := map[string][]string{ + "github_app_registrations": {"display_name TEXT NOT NULL", "UNIQUE (github_host, app_id)"}, + "github_workspace_connections": {"FOREIGN KEY (app_registration_id)", "source = 'github_app_installation'"}, + "github_user_connections": {"app_registration_id TEXT NOT NULL", "FOREIGN KEY (app_registration_id)"}, + "github_auth_flows": {"app_registration_id TEXT NOT NULL", "expected_workspace_app_registration_id TEXT"}, + "github_webhook_deliveries": {"PRIMARY KEY (app_registration_id, delivery_id)"}, + } + for table, fragments := range required { + var schema string + err := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&schema) + if errors.Is(err, sql.ErrNoRows) { + continue + } + if err != nil { + return false, err + } + for _, fragment := range fragments { + if !strings.Contains(schema, fragment) { + return true, nil + } + } + } + return false, nil +} + +func (s *Store) addAppRegistrationReferenceColumns() error { + for _, migration := range []struct { + table string + statement string + }{ + {"github_workspace_connections", `ALTER TABLE github_workspace_connections ADD COLUMN app_registration_id TEXT`}, + {"github_user_connections", `ALTER TABLE github_user_connections ADD COLUMN app_registration_id TEXT`}, + {"github_auth_flows", `ALTER TABLE github_auth_flows ADD COLUMN app_registration_id TEXT`}, + } { + columns, err := s.tableColumns(migration.table) + if err != nil { + return fmt.Errorf("read %s columns: %w", migration.table, err) + } + if _, exists := columns["app_registration_id"]; exists { + continue + } + if _, err := s.db.Exec(migration.statement); err != nil { + return fmt.Errorf("add %s.app_registration_id: %w", migration.table, err) + } + } + return nil +} + +func (s *Store) initCoreSchema() error { + if err := s.initAppRegistrationSchema(); err != nil { + return err + } + _, err := s.db.Exec(createTablesSQL) + return err +} + +func (s *Store) initAppRegistrationSchema() error { + if _, err := s.db.Exec(appRegistrationTablesSQL); err != nil { + return fmt.Errorf("initialize GitHub App registration schema: %w", err) + } + return nil +} + +func (s *Store) backfillGitHubUserConnectionVersions() error { + // Package-local tests may initialize the GitHub store before the shared + // workspace schema. A database with existing user connections necessarily + // has the workspace table, so skipping this empty backfill is lossless. + if !s.tableExists("workspaces") { + return nil + } + if _, err := s.db.Exec(` + INSERT INTO github_user_connection_versions (workspace_id, user_id, credential_generation, updated_at) + SELECT workspace_id, user_id, credential_generation, updated_at FROM github_user_connections + WHERE true + ON CONFLICT(workspace_id, user_id) DO UPDATE SET + credential_generation = MAX(github_user_connection_versions.credential_generation, excluded.credential_generation), + updated_at = excluded.updated_at`); err != nil { + return fmt.Errorf("backfill GitHub user connection versions: %w", err) + } + return nil +} + +func (s *Store) addGitHubAuthFlowExpectationColumns() error { + columns, err := s.tableColumns("github_auth_flows") + if err != nil { + return fmt.Errorf("read github_auth_flows columns: %w", err) + } + for name, statement := range map[string]string{ + "expected_workspace_source": `ALTER TABLE github_auth_flows ADD COLUMN expected_workspace_source TEXT NOT NULL DEFAULT ''`, + "expected_workspace_generation": `ALTER TABLE github_auth_flows ADD COLUMN expected_workspace_generation BIGINT NOT NULL DEFAULT 0`, + "expected_installation_id": `ALTER TABLE github_auth_flows ADD COLUMN expected_installation_id BIGINT`, + "expected_workspace_app_registration_id": `ALTER TABLE github_auth_flows ADD COLUMN expected_workspace_app_registration_id TEXT`, + "expected_personal_generation": `ALTER TABLE github_auth_flows ADD COLUMN expected_personal_generation BIGINT NOT NULL DEFAULT 0`, + } { + if _, ok := columns[name]; ok { + continue + } + if _, err := s.db.Exec(statement); err != nil { + return fmt.Errorf("add github_auth_flows.%s: %w", name, err) + } + } + return nil +} + +func (s *Store) ensureWorkspaceOwnershipIndexes() error { + for name, statement := range map[string]string{ + "github_task_prs": `CREATE INDEX IF NOT EXISTS idx_github_task_prs_workspace ON github_task_prs (workspace_id)`, + "github_pr_watches": `CREATE INDEX IF NOT EXISTS idx_github_pr_watches_workspace ON github_pr_watches (workspace_id)`, + } { + if _, err := s.db.Exec(statement); err != nil { + return fmt.Errorf("index %s workspace ownership: %w", name, err) + } + } + return nil +} + +func (s *Store) addWorkspaceOwnershipColumns() error { + for _, migration := range []struct { + table string + stmt string + }{ + {"github_pr_watches", `ALTER TABLE github_pr_watches ADD COLUMN workspace_id TEXT NOT NULL DEFAULT ''`}, + {"github_task_prs", `ALTER TABLE github_task_prs ADD COLUMN workspace_id TEXT NOT NULL DEFAULT ''`}, + } { + if _, err := s.db.Exec(migration.stmt); err != nil && !dbutil.IsDuplicateColumnError(err) { + return fmt.Errorf("add %s.workspace_id: %w", migration.table, err) + } + } + return nil +} + +func (s *Store) addReviewWatchTargetLogin() error { + _, err := s.db.Exec(`ALTER TABLE github_review_watches ADD COLUMN target_login TEXT NOT NULL DEFAULT ''`) + if err != nil && !dbutil.IsDuplicateColumnError(err) { + return fmt.Errorf("add github_review_watches.target_login: %w", err) + } + return nil +} + +func (s *Store) backfillGitHubWorkspaceOwnership() error { + if !s.tableExists("tasks") { + return nil + } + taskColumns, err := s.tableColumns("tasks") + if err != nil { + return fmt.Errorf("read tasks columns for github ownership backfill: %w", err) + } + if _, ok := taskColumns["workspace_id"]; !ok { + return nil + } + for _, table := range []string{"github_pr_watches", "github_task_prs"} { + query := `UPDATE ` + table + ` + SET workspace_id = ( + SELECT tasks.workspace_id FROM tasks WHERE tasks.id = ` + table + `.task_id + ) + WHERE workspace_id = '' + AND EXISTS (SELECT 1 FROM tasks WHERE tasks.id = ` + table + `.task_id) + AND (SELECT tasks.workspace_id FROM tasks WHERE tasks.id = ` + table + `.task_id) <> ''` + if _, err := s.db.Exec(query); err != nil { + return fmt.Errorf("backfill %s.workspace_id: %w", table, err) + } + } + return nil +} + +func (s *Store) seedLegacyWorkspaceConnections() error { + if !s.tableExists("workspaces") { + return nil + } + _, err := s.db.Exec(` + INSERT INTO github_workspace_connections ( + workspace_id, source, github_host, status, credential_generation, created_at, updated_at + ) + SELECT id, 'legacy_shared', 'github.com', 'active', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM workspaces + WHERE TRUE + ON CONFLICT(workspace_id) DO NOTHING`) + if err != nil { + return fmt.Errorf("seed legacy github workspace connections: %w", err) + } return nil } @@ -478,6 +986,7 @@ func (s *Store) migratePRTablesForMultiRepo() error { trigger, `CREATE TABLE github_pr_watches_new ( id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', session_id TEXT NOT NULL, task_id TEXT NOT NULL, repository_id TEXT NOT NULL DEFAULT '', @@ -494,11 +1003,11 @@ func (s *Store) migratePRTablesForMultiRepo() error { UNIQUE(session_id, repository_id, branch) )`, `INSERT INTO github_pr_watches_new ( - id, session_id, task_id, repository_id, owner, repo, pr_number, branch, + id, workspace_id, session_id, task_id, repository_id, owner, repo, pr_number, branch, last_checked_at, last_comment_at, last_check_status, last_review_state, created_at, updated_at ) SELECT - id, session_id, task_id, COALESCE(repository_id, ''), owner, repo, pr_number, branch, + id, COALESCE(workspace_id, ''), session_id, task_id, COALESCE(repository_id, ''), owner, repo, pr_number, branch, last_checked_at, last_comment_at, last_check_status, last_review_state, created_at, updated_at FROM github_pr_watches`, @@ -511,6 +1020,7 @@ func (s *Store) migratePRTablesForMultiRepo() error { "UNIQUE(task_id, pr_number)", `CREATE TABLE github_task_prs_new ( id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', task_id TEXT NOT NULL, repository_id TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL, @@ -542,12 +1052,12 @@ func (s *Store) migratePRTablesForMultiRepo() error { UNIQUE(task_id, repository_id, pr_number) )`, `INSERT INTO github_task_prs_new ( - id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, + id, workspace_id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, state, review_state, checks_state, mergeable_state, review_count, pending_review_count, comment_count, additions, deletions, created_at, merged_at, closed_at, last_synced_at, updated_at ) SELECT - id, task_id, COALESCE(repository_id, ''), owner, repo, pr_number, pr_url, pr_title, + id, COALESCE(workspace_id, ''), task_id, COALESCE(repository_id, ''), owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, state, review_state, checks_state, mergeable_state, review_count, pending_review_count, comment_count, additions, deletions, created_at, merged_at, closed_at, last_synced_at, updated_at @@ -603,9 +1113,9 @@ func (s *Store) CreatePRWatch(ctx context.Context, w *PRWatch) error { w.CreatedAt = now w.UpdatedAt = now _, err := s.db.ExecContext(ctx, ` - INSERT INTO github_pr_watches (id, session_id, task_id, repository_id, owner, repo, pr_number, branch, last_check_status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - w.ID, w.SessionID, w.TaskID, w.RepositoryID, w.Owner, w.Repo, w.PRNumber, w.Branch, w.LastCheckStatus, w.CreatedAt, w.UpdatedAt) + INSERT INTO github_pr_watches (id, workspace_id, session_id, task_id, repository_id, owner, repo, pr_number, branch, last_check_status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + w.ID, w.WorkspaceID, w.SessionID, w.TaskID, w.RepositoryID, w.Owner, w.Repo, w.PRNumber, w.Branch, w.LastCheckStatus, w.CreatedAt, w.UpdatedAt) return err } @@ -622,6 +1132,16 @@ func (s *Store) GetPRWatchBySession(ctx context.Context, sessionID string) (*PRW return &w, err } +// GetPRWatch returns a PR watch by ID. +func (s *Store) GetPRWatch(ctx context.Context, id string) (*PRWatch, error) { + var w PRWatch + err := s.ro.GetContext(ctx, &w, `SELECT * FROM github_pr_watches WHERE id = ?`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &w, err +} + // GetPRWatchBySessionAndRepo returns the PR watch for a (session, repository) // pair, or nil. Used by per-repo branch-switch / commit handlers so each // repo's watch is reset independently. @@ -703,6 +1223,17 @@ func (s *Store) ListActivePRWatches(ctx context.Context) ([]*PRWatch, error) { return watches, err } +// ListActivePRWatchesForWorkspace returns active watches only for one workspace. +func (s *Store) ListActivePRWatchesForWorkspace(ctx context.Context, workspaceID string) ([]*PRWatch, error) { + var watches []*PRWatch + err := s.ro.SelectContext(ctx, &watches, ` + SELECT w.* FROM github_pr_watches w + INNER JOIN tasks t ON t.id = w.task_id + WHERE w.workspace_id = ? AND t.archived_at IS NULL + ORDER BY w.created_at`, workspaceID) + return watches, err +} + // UpdatePRWatchTimestamps updates the last checked timestamps and status fields. func (s *Store) UpdatePRWatchTimestamps(ctx context.Context, id string, checkedAt time.Time, commentAt *time.Time, checkStatus, reviewState string) error { _, err := s.db.ExecContext(ctx, ` @@ -834,12 +1365,12 @@ func (s *Store) CreateTaskPR(ctx context.Context, tp *TaskPR) error { now := time.Now().UTC() tp.UpdatedAt = now _, err := s.db.ExecContext(ctx, ` - INSERT INTO github_task_prs (id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, + INSERT INTO github_task_prs (id, workspace_id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, state, review_state, checks_state, mergeable_state, review_count, pending_review_count, required_reviews, comment_count, unresolved_review_threads, checks_total, checks_passing, additions, deletions, created_at, merged_at, closed_at, last_synced_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - tp.ID, tp.TaskID, tp.RepositoryID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tp.ID, tp.WorkspaceID, tp.TaskID, tp.RepositoryID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, tp.State, tp.ReviewState, tp.ChecksState, tp.MergeableState, tp.ReviewCount, tp.PendingReviewCount, tp.RequiredReviews, tp.CommentCount, tp.UnresolvedReviewThreads, tp.ChecksTotal, tp.ChecksPassing, tp.Additions, tp.Deletions, tp.CreatedAt, tp.MergedAt, tp.ClosedAt, tp.LastSyncedAt, tp.UpdatedAt) @@ -1023,12 +1554,12 @@ func (s *Store) ReplaceTaskPR(ctx context.Context, tp *TaskPR) error { } } if _, err := tx.ExecContext(ctx, ` - INSERT INTO github_task_prs (id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, + INSERT INTO github_task_prs (id, workspace_id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, state, review_state, checks_state, mergeable_state, review_count, pending_review_count, required_reviews, comment_count, unresolved_review_threads, checks_total, checks_passing, additions, deletions, created_at, merged_at, closed_at, last_synced_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - tp.ID, tp.TaskID, tp.RepositoryID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tp.ID, tp.WorkspaceID, tp.TaskID, tp.RepositoryID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, tp.State, tp.ReviewState, tp.ChecksState, tp.MergeableState, tp.ReviewCount, tp.PendingReviewCount, tp.RequiredReviews, tp.CommentCount, tp.UnresolvedReviewThreads, tp.ChecksTotal, tp.ChecksPassing, tp.Additions, tp.Deletions, tp.CreatedAt, tp.MergedAt, tp.ClosedAt, tp.LastSyncedAt, tp.UpdatedAt); err != nil { @@ -1302,11 +1833,11 @@ func (s *Store) CreateReviewWatch(ctx context.Context, rw *ReviewWatch) error { rw.ReposJSON = string(reposJSON) _, err = s.db.ExecContext(ctx, ` INSERT INTO github_review_watches (id, workspace_id, workflow_id, workflow_step_id, repos, - agent_profile_id, executor_profile_id, prompt, review_scope, custom_query, + agent_profile_id, executor_profile_id, prompt, review_scope, custom_query, target_login, enabled, poll_interval_seconds, cleanup_policy, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, rw.ID, rw.WorkspaceID, rw.WorkflowID, rw.WorkflowStepID, rw.ReposJSON, - rw.AgentProfileID, rw.ExecutorProfileID, rw.Prompt, rw.ReviewScope, rw.CustomQuery, + rw.AgentProfileID, rw.ExecutorProfileID, rw.Prompt, rw.ReviewScope, rw.CustomQuery, rw.TargetLogin, rw.Enabled, rw.PollIntervalSeconds, rw.CleanupPolicy, rw.CreatedAt, rw.UpdatedAt) return err } @@ -1396,12 +1927,12 @@ func (s *Store) UpdateReviewWatch(ctx context.Context, rw *ReviewWatch) error { _, err = s.db.ExecContext(ctx, ` UPDATE github_review_watches SET workflow_id = ?, workflow_step_id = ?, repos = ?, agent_profile_id = ?, executor_profile_id = ?, - prompt = ?, review_scope = ?, custom_query = ?, + prompt = ?, review_scope = ?, custom_query = ?, target_login = ?, enabled = ?, poll_interval_seconds = ?, cleanup_policy = ?, last_polled_at = ?, updated_at = ? WHERE id = ?`, rw.WorkflowID, rw.WorkflowStepID, rw.ReposJSON, rw.AgentProfileID, rw.ExecutorProfileID, - rw.Prompt, rw.ReviewScope, rw.CustomQuery, + rw.Prompt, rw.ReviewScope, rw.CustomQuery, rw.TargetLogin, rw.Enabled, rw.PollIntervalSeconds, rw.CleanupPolicy, rw.LastPolledAt, rw.UpdatedAt, rw.ID) return err } diff --git a/apps/backend/internal/github/store_connections.go b/apps/backend/internal/github/store_connections.go new file mode 100644 index 0000000000..93f92a859b --- /dev/null +++ b/apps/backend/internal/github/store_connections.go @@ -0,0 +1,563 @@ +package github + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// ErrAuthFlowUnavailable means an OAuth/setup state cannot be consumed. +var ErrAuthFlowUnavailable = errors.New("github auth flow is expired, consumed, or missing") + +const workspaceConnectionSelect = ` + SELECT workspace_id, source, github_host, COALESCE(login, '') AS login, + installation_id, COALESCE(installation_account_login, '') AS installation_account_login, + COALESCE(installation_account_type, '') AS installation_account_type, + COALESCE(app_registration_id, '') AS app_registration_id, + status, credential_generation, COALESCE(last_error, '') AS last_error, + created_at, updated_at + FROM github_workspace_connections` + +const userConnectionSelect = ` + SELECT workspace_id, user_id, app_registration_id, github_user_id, login, status, access_expires_at, + refresh_expires_at, credential_generation, COALESCE(last_error, '') AS last_error, + created_at, updated_at + FROM github_user_connections` + +// WorkspaceConnectionHealth summarizes persisted connection state across all +// workspaces, including workspaces with no connection row. +type WorkspaceConnectionHealth struct { + WorkspaceCount int + Active int + Disconnected int + Invalid int + Suspended int + Revoked int +} + +// GetWorkspaceConnectionHealth returns aggregate workspace-owned GitHub +// health without consulting ambient process credentials. +func (s *Store) GetWorkspaceConnectionHealth(ctx context.Context) (WorkspaceConnectionHealth, error) { + var health WorkspaceConnectionHealth + err := s.ro.QueryRowxContext(ctx, ` + SELECT COUNT(w.id), + COALESCE(SUM(CASE WHEN c.status = 'active' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN c.workspace_id IS NULL THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN c.status = 'invalid' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN c.status = 'suspended' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN c.status = 'revoked' THEN 1 ELSE 0 END), 0) + FROM workspaces w + LEFT JOIN github_workspace_connections c ON c.workspace_id = w.id`).Scan( + &health.WorkspaceCount, + &health.Active, + &health.Disconnected, + &health.Invalid, + &health.Suspended, + &health.Revoked, + ) + return health, err +} + +// GetWorkspaceConnection returns a workspace's automation connection, if any. +func (s *Store) GetWorkspaceConnection(ctx context.Context, workspaceID string) (*WorkspaceConnection, error) { + var connection WorkspaceConnection + err := s.ro.GetContext(ctx, &connection, + s.ro.Rebind(workspaceConnectionSelect+` WHERE workspace_id = ?`), workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &connection, err +} + +// ListWorkspaceConnectionsByInstallation returns every workspace bound to an installation. +func (s *Store) ListWorkspaceConnectionsByInstallation( + ctx context.Context, + installationID int64, +) ([]*WorkspaceConnection, error) { + var connections []*WorkspaceConnection + err := s.ro.SelectContext(ctx, &connections, + s.ro.Rebind(workspaceConnectionSelect+` WHERE installation_id = ? ORDER BY workspace_id`), installationID) + return connections, err +} + +func (s *Store) ListWorkspaceConnectionsByAppInstallation( + ctx context.Context, + registrationID string, + installationID int64, +) ([]*WorkspaceConnection, error) { + var connections []*WorkspaceConnection + err := s.ro.SelectContext(ctx, &connections, s.ro.Rebind( + workspaceConnectionSelect+` + WHERE app_registration_id = ? AND installation_id = ? ORDER BY workspace_id`), + registrationID, installationID) + return connections, err +} + +// UpsertWorkspaceConnection creates or replaces workspace automation metadata. +func (s *Store) UpsertWorkspaceConnection(ctx context.Context, connection *WorkspaceConnection) error { + if connection == nil { + return fmt.Errorf("workspace connection is required") + } + now := time.Now().UTC() + if connection.CreatedAt.IsZero() { + connection.CreatedAt = now + } + if connection.CredentialGeneration < 1 { + connection.CredentialGeneration = 1 + } + connection.UpdatedAt = now + _, err := s.db.ExecContext(ctx, s.db.Rebind(` + INSERT INTO github_workspace_connections ( + workspace_id, source, github_host, login, installation_id, + installation_account_login, installation_account_type, app_registration_id, status, + credential_generation, last_error, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(workspace_id) DO UPDATE SET + source = excluded.source, + github_host = excluded.github_host, + login = excluded.login, + installation_id = excluded.installation_id, + installation_account_login = excluded.installation_account_login, + installation_account_type = excluded.installation_account_type, + app_registration_id = excluded.app_registration_id, + status = excluded.status, + credential_generation = excluded.credential_generation, + last_error = excluded.last_error, + updated_at = excluded.updated_at`), + connection.WorkspaceID, connection.Source, connection.GitHubHost, nullString(connection.Login), + connection.InstallationID, nullString(connection.InstallationAccountLogin), + nullString(connection.InstallationAccountType), nullString(connection.AppRegistrationID), connection.Status, + connection.CredentialGeneration, nullString(connection.LastError), connection.CreatedAt, connection.UpdatedAt) + return err +} + +// TransitionWorkspaceInstallationConnection applies a webhook lifecycle change +// only while the workspace is still bound to the exact installation state that +// was listed by the webhook handler. +func (s *Store) TransitionWorkspaceInstallationConnection( + ctx context.Context, + expected, next *WorkspaceConnection, +) (bool, error) { + if expected == nil || next == nil || expected.InstallationID == nil || + next.WorkspaceID != expected.WorkspaceID { + return false, fmt.Errorf("expected and next installation connections are required") + } + now := time.Now().UTC() + result, err := s.db.ExecContext(ctx, s.db.Rebind(` + UPDATE github_workspace_connections + SET installation_account_login = ?, installation_account_type = ?, status = ?, + credential_generation = ?, last_error = ?, updated_at = ? + WHERE workspace_id = ? AND source = ? AND installation_id = ? + AND app_registration_id = ? AND credential_generation = ? AND status = ?`), + nullString(next.InstallationAccountLogin), nullString(next.InstallationAccountType), next.Status, + next.CredentialGeneration, nullString(next.LastError), now, + expected.WorkspaceID, expected.Source, *expected.InstallationID, + expected.AppRegistrationID, expected.CredentialGeneration, expected.Status, + ) + if err != nil { + return false, err + } + count, err := result.RowsAffected() + if count == 1 { + next.UpdatedAt = now + } + return count == 1, err +} + +// DeleteWorkspaceConnection removes workspace automation metadata. +func (s *Store) DeleteWorkspaceConnection(ctx context.Context, workspaceID string) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind( + `DELETE FROM github_workspace_connections WHERE workspace_id = ?`), workspaceID) + return err +} + +// GetUserConnection returns a user's personal connection in a workspace, if any. +func (s *Store) GetUserConnection(ctx context.Context, workspaceID, userID string) (*UserConnection, error) { + var connection UserConnection + err := s.ro.GetContext(ctx, &connection, + s.ro.Rebind(userConnectionSelect+` WHERE workspace_id = ? AND user_id = ?`), workspaceID, userID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &connection, err +} + +func (s *Store) GetUserConnectionGeneration(ctx context.Context, workspaceID, userID string) (int64, error) { + var generation int64 + err := s.ro.GetContext(ctx, &generation, s.ro.Rebind(` + SELECT credential_generation FROM github_user_connection_versions + WHERE workspace_id = ? AND user_id = ?`), workspaceID, userID) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + return generation, err +} + +// ListUserConnectionsByGitHubUser returns bindings for one verified GitHub user. +func (s *Store) ListUserConnectionsByGitHubUser( + ctx context.Context, + githubUserID int64, +) ([]*UserConnection, error) { + var connections []*UserConnection + err := s.ro.SelectContext(ctx, &connections, + s.ro.Rebind(userConnectionSelect+` WHERE github_user_id = ? ORDER BY workspace_id, user_id`), githubUserID) + return connections, err +} + +func (s *Store) ListUserConnectionsByAppGitHubUser( + ctx context.Context, + registrationID string, + githubUserID int64, +) ([]*UserConnection, error) { + var connections []*UserConnection + err := s.ro.SelectContext(ctx, &connections, s.ro.Rebind( + userConnectionSelect+` + WHERE app_registration_id = ? AND github_user_id = ? ORDER BY workspace_id, user_id`), + registrationID, githubUserID) + return connections, err +} + +// ListUserConnectionsByWorkspace returns all personal identities owned by a workspace. +func (s *Store) ListUserConnectionsByWorkspace( + ctx context.Context, + workspaceID string, +) ([]*UserConnection, error) { + var connections []*UserConnection + err := s.ro.SelectContext(ctx, &connections, + s.ro.Rebind(userConnectionSelect+` WHERE workspace_id = ? ORDER BY user_id`), workspaceID) + return connections, err +} + +// UpsertUserConnection creates or replaces personal connection metadata. +func (s *Store) UpsertUserConnection(ctx context.Context, connection *UserConnection) error { + if connection == nil { + return fmt.Errorf("user connection is required") + } + workspace, err := s.GetWorkspaceConnection(ctx, connection.WorkspaceID) + if err != nil { + return err + } + if workspace == nil || workspace.Source != ConnectionSourceGitHubAppInstallation || + workspace.AppRegistrationID == "" || workspace.AppRegistrationID != connection.AppRegistrationID { + return ErrOAuthFlowStale + } + now := time.Now().UTC() + if connection.CreatedAt.IsZero() { + connection.CreatedAt = now + } + if connection.CredentialGeneration < 1 { + connection.CredentialGeneration = 1 + } + connection.UpdatedAt = now + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, tx.Rebind(` + INSERT INTO github_user_connections ( + workspace_id, user_id, app_registration_id, github_user_id, login, status, access_expires_at, + refresh_expires_at, credential_generation, last_error, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(workspace_id, user_id) DO UPDATE SET + app_registration_id = excluded.app_registration_id, + github_user_id = excluded.github_user_id, + login = excluded.login, + status = excluded.status, + access_expires_at = excluded.access_expires_at, + refresh_expires_at = excluded.refresh_expires_at, + credential_generation = excluded.credential_generation, + last_error = excluded.last_error, + updated_at = excluded.updated_at`), + connection.WorkspaceID, connection.UserID, connection.AppRegistrationID, + connection.GitHubUserID, connection.Login, + connection.Status, connection.AccessExpiresAt, connection.RefreshExpiresAt, + connection.CredentialGeneration, nullString(connection.LastError), connection.CreatedAt, connection.UpdatedAt); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, tx.Rebind(` + INSERT INTO github_user_connection_versions (workspace_id, user_id, credential_generation, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(workspace_id, user_id) DO UPDATE SET + credential_generation = MAX(github_user_connection_versions.credential_generation, excluded.credential_generation), + updated_at = excluded.updated_at`), + connection.WorkspaceID, connection.UserID, connection.CredentialGeneration, connection.UpdatedAt); err != nil { + return err + } + return tx.Commit() +} + +// DeleteUserConnection removes one user's personal workspace connection. +func (s *Store) DeleteUserConnection(ctx context.Context, workspaceID, userID string) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, tx.Rebind(` + INSERT INTO github_user_connection_versions (workspace_id, user_id, credential_generation, updated_at) + VALUES (?, ?, COALESCE((SELECT credential_generation + 1 FROM github_user_connections + WHERE workspace_id = ? AND user_id = ?), 1), ?) + ON CONFLICT(workspace_id, user_id) DO UPDATE SET + credential_generation = MAX( + github_user_connection_versions.credential_generation + 1, + excluded.credential_generation + ), updated_at = excluded.updated_at`), + workspaceID, userID, workspaceID, userID, time.Now().UTC()); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, tx.Rebind( + `DELETE FROM github_user_connections WHERE workspace_id = ? AND user_id = ?`), workspaceID, userID); err != nil { + return err + } + return tx.Commit() +} + +// DeleteUserConnectionsByWorkspace removes all personal identities owned by a workspace. +func (s *Store) DeleteUserConnectionsByWorkspace(ctx context.Context, workspaceID string) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, tx.Rebind(` + INSERT INTO github_user_connection_versions (workspace_id, user_id, credential_generation, updated_at) + SELECT workspace_id, user_id, credential_generation + 1, ? + FROM github_user_connections WHERE workspace_id = ? + ON CONFLICT(workspace_id, user_id) DO UPDATE SET + credential_generation = MAX( + github_user_connection_versions.credential_generation + 1, + excluded.credential_generation + ), updated_at = excluded.updated_at`), time.Now().UTC(), workspaceID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, tx.Rebind( + `DELETE FROM github_user_connections WHERE workspace_id = ?`), workspaceID); err != nil { + return err + } + return tx.Commit() +} + +// DeleteWorkspaceAuthData removes workspace-owned GitHub auth metadata. Secret +// values use the deterministic keys in models.go and must be removed by the +// service in the same higher-level cleanup operation. +func (s *Store) DeleteWorkspaceAuthData(ctx context.Context, workspaceID string) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + for _, query := range []string{ + `DELETE FROM github_app_registration_flows WHERE workspace_id = ?`, + `DELETE FROM github_auth_flows WHERE workspace_id = ?`, + `DELETE FROM github_user_connections WHERE workspace_id = ?`, + `DELETE FROM github_user_connection_versions WHERE workspace_id = ?`, + `DELETE FROM github_workspace_connections WHERE workspace_id = ?`, + } { + if _, err := tx.ExecContext(ctx, tx.Rebind(query), workspaceID); err != nil { + return err + } + } + return tx.Commit() +} + +// DeleteAllMockAuthData resets the mock-only connection state without +// touching GitHub watches or repository fixtures. +func (s *Store) DeleteAllMockAuthData(ctx context.Context) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + for _, query := range []string{ + `DELETE FROM github_auth_flows`, + `DELETE FROM github_user_connections`, + `DELETE FROM github_user_connection_versions`, + `DELETE FROM github_workspace_connections`, + } { + if _, err := tx.ExecContext(ctx, query); err != nil { + return err + } + } + return tx.Commit() +} + +// CreateAuthFlow persists one short-lived OAuth/setup flow. +func (s *Store) CreateAuthFlow(ctx context.Context, flow *AuthFlow) error { + if flow == nil { + return fmt.Errorf("auth flow is required") + } + if flow.CreatedAt.IsZero() { + flow.CreatedAt = time.Now().UTC() + } + _, err := s.db.ExecContext(ctx, s.db.Rebind(` + INSERT INTO github_auth_flows ( + state_hash, workspace_id, user_id, app_registration_id, kind, pkce_verifier, + expected_workspace_source, expected_workspace_generation, expected_installation_id, + expected_workspace_app_registration_id, expected_personal_generation, expires_at, consumed_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), + flow.StateHash, flow.WorkspaceID, flow.UserID, flow.AppRegistrationID, flow.Kind, flow.PKCEVerifier, + flow.ExpectedWorkspaceSource, flow.ExpectedWorkspaceGeneration, flow.ExpectedInstallationID, + nullString(flow.ExpectedWorkspaceAppRegistrationID), + flow.ExpectedPersonalGeneration, + flow.ExpiresAt, flow.ConsumedAt, flow.CreatedAt) + return err +} + +// GetAuthFlow returns auth flow state without consuming it. +func (s *Store) GetAuthFlow(ctx context.Context, stateHash string) (*AuthFlow, error) { + var flow AuthFlow + err := s.ro.GetContext(ctx, &flow, s.ro.Rebind(` + SELECT state_hash, workspace_id, user_id, app_registration_id, kind, pkce_verifier, + expected_workspace_source, expected_workspace_generation, expected_installation_id, + COALESCE(expected_workspace_app_registration_id, '') AS expected_workspace_app_registration_id, + expected_personal_generation, expires_at, consumed_at, created_at + FROM github_auth_flows WHERE state_hash = ?`), stateHash) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &flow, err +} + +// ConsumeAuthFlow atomically claims one unexpired, unused flow. +func (s *Store) ConsumeAuthFlow( + ctx context.Context, + stateHash, registrationID string, + kind AuthFlowKind, + now time.Time, +) (*AuthFlow, error) { + result, err := s.db.ExecContext(ctx, s.db.Rebind(` + UPDATE github_auth_flows SET consumed_at = ? + WHERE state_hash = ? AND app_registration_id = ? AND kind = ? + AND consumed_at IS NULL AND expires_at > ?`), now, stateHash, registrationID, kind, now) + if err != nil { + return nil, err + } + count, err := result.RowsAffected() + if err != nil { + return nil, err + } + if count != 1 { + return nil, ErrAuthFlowUnavailable + } + return s.GetAuthFlow(ctx, stateHash) +} + +// RecordWebhookDelivery returns false for a duplicate delivery ID. +func (s *Store) RecordWebhookDelivery(ctx context.Context, delivery *WebhookDelivery) (bool, error) { + if delivery == nil { + return false, fmt.Errorf("webhook delivery is required") + } + if delivery.Status == "" { + delivery.Status = WebhookDeliveryStatusReceived + } + if delivery.ReceivedAt.IsZero() { + delivery.ReceivedAt = time.Now().UTC() + } + result, err := s.db.ExecContext(ctx, s.db.Rebind(` + INSERT INTO github_webhook_deliveries ( + app_registration_id, delivery_id, event, status, result, received_at, processed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(app_registration_id, delivery_id) DO NOTHING`), + delivery.AppRegistrationID, delivery.DeliveryID, delivery.Event, delivery.Status, delivery.Result, + delivery.ReceivedAt, delivery.ProcessedAt) + if err != nil { + return false, err + } + count, err := result.RowsAffected() + return count == 1, err +} + +// ClaimWebhookDelivery inserts a new delivery or atomically reclaims a failed +// or stale in-flight delivery. Only one concurrent caller can acquire a retry. +func (s *Store) ClaimWebhookDelivery( + ctx context.Context, + delivery *WebhookDelivery, + staleBefore time.Time, +) (WebhookDeliveryClaim, error) { + if delivery == nil { + return WebhookDeliveryClaim{}, fmt.Errorf("webhook delivery is required") + } + if delivery.ReceivedAt.IsZero() { + delivery.ReceivedAt = time.Now().UTC() + } + result, err := s.db.ExecContext(ctx, s.db.Rebind(` + INSERT INTO github_webhook_deliveries ( + app_registration_id, delivery_id, event, status, result, received_at, processed_at + ) VALUES (?, ?, ?, ?, '', ?, NULL) + ON CONFLICT(app_registration_id, delivery_id) DO UPDATE SET + event = excluded.event, + status = excluded.status, + result = '', + received_at = excluded.received_at, + processed_at = NULL + WHERE github_webhook_deliveries.status = ? + OR (github_webhook_deliveries.status = ? AND github_webhook_deliveries.received_at <= ?)`), + delivery.AppRegistrationID, delivery.DeliveryID, delivery.Event, + WebhookDeliveryStatusReceived, delivery.ReceivedAt, + WebhookDeliveryStatusFailed, WebhookDeliveryStatusReceived, staleBefore, + ) + if err != nil { + return WebhookDeliveryClaim{}, err + } + count, err := result.RowsAffected() + if err != nil { + return WebhookDeliveryClaim{}, err + } + if count == 1 { + return WebhookDeliveryClaim{Acquired: true, Status: WebhookDeliveryStatusReceived}, nil + } + var status WebhookDeliveryStatus + if err := s.ro.GetContext(ctx, &status, s.ro.Rebind(` + SELECT status FROM github_webhook_deliveries + WHERE app_registration_id = ? AND delivery_id = ?`), + delivery.AppRegistrationID, delivery.DeliveryID); err != nil { + return WebhookDeliveryClaim{}, err + } + return WebhookDeliveryClaim{Status: status}, nil +} + +// CompleteWebhookDelivery records the terminal webhook processing result. +func (s *Store) CompleteWebhookDelivery( + ctx context.Context, + deliveryID string, + status WebhookDeliveryStatus, + result string, + processedAt time.Time, +) error { + var registrationID string + err := s.ro.GetContext(ctx, ®istrationID, s.ro.Rebind(` + SELECT app_registration_id FROM github_webhook_deliveries WHERE delivery_id = ? + GROUP BY app_registration_id HAVING COUNT(*) = 1`), deliveryID) + if err != nil { + return err + } + return s.CompleteAppRegistrationWebhookDelivery( + ctx, registrationID, deliveryID, status, result, processedAt, + ) +} + +func (s *Store) CompleteAppRegistrationWebhookDelivery( + ctx context.Context, + registrationID, deliveryID string, + status WebhookDeliveryStatus, + result string, + processedAt time.Time, +) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind(` + UPDATE github_webhook_deliveries + SET status = ?, result = ?, processed_at = ? + WHERE app_registration_id = ? AND delivery_id = ?`), + status, result, processedAt, registrationID, deliveryID) + return err +} + +func nullString(value string) any { + if strings.TrimSpace(value) == "" { + return nil + } + return value +} diff --git a/apps/backend/internal/github/store_connections_test.go b/apps/backend/internal/github/store_connections_test.go new file mode 100644 index 0000000000..bbde5813c8 --- /dev/null +++ b/apps/backend/internal/github/store_connections_test.go @@ -0,0 +1,656 @@ +package github + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" +) + +func TestStoreWorkspaceConnectionRoundTrip(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-1") + seedStoreAppRegistration(t, store) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + installationID := int64(12345) + connection := &WorkspaceConnection{ + WorkspaceID: "ws-1", + Source: ConnectionSourceGitHubAppInstallation, + GitHubHost: "github.com", + InstallationID: &installationID, + InstallationAccountLogin: "acme", + InstallationAccountType: "Organization", + AppRegistrationID: "registration-store-test", + Status: ConnectionStatusActive, + CredentialGeneration: 3, + CreatedAt: now, + UpdatedAt: now, + } + + if err := store.UpsertWorkspaceConnection(ctx, connection); err != nil { + t.Fatalf("upsert workspace connection: %v", err) + } + got, err := store.GetWorkspaceConnection(ctx, "ws-1") + if err != nil { + t.Fatalf("get workspace connection: %v", err) + } + if got == nil || got.Source != connection.Source || got.InstallationID == nil || *got.InstallationID != installationID { + t.Fatalf("workspace connection = %+v, want source %q installation %d", got, connection.Source, installationID) + } + + connection.Source = ConnectionSourcePAT + connection.InstallationID = nil + connection.InstallationAccountLogin = "" + connection.InstallationAccountType = "" + connection.AppRegistrationID = "" + connection.Login = "octocat" + connection.CredentialGeneration = 4 + if err := store.UpsertWorkspaceConnection(ctx, connection); err != nil { + t.Fatalf("replace workspace connection: %v", err) + } + got, err = store.GetWorkspaceConnection(ctx, "ws-1") + if err != nil { + t.Fatalf("get replaced workspace connection: %v", err) + } + if got == nil || got.Source != ConnectionSourcePAT || got.Login != "octocat" || got.CredentialGeneration != 4 { + t.Fatalf("replaced workspace connection = %+v", got) + } +} + +func TestStoreInstallationTransitionRejectsReplacedWorkspaceConnection(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-1") + seedStoreAppRegistration(t, store) + ctx := context.Background() + installationID := int64(12345) + expected := &WorkspaceConnection{ + WorkspaceID: "ws-1", Source: ConnectionSourceGitHubAppInstallation, + GitHubHost: "github.com", InstallationID: &installationID, + InstallationAccountLogin: "acme", InstallationAccountType: "Organization", + AppRegistrationID: "registration-store-test", + Status: ConnectionStatusActive, CredentialGeneration: 2, + } + if err := store.UpsertWorkspaceConnection(ctx, expected); err != nil { + t.Fatal(err) + } + if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ + WorkspaceID: "ws-1", Source: ConnectionSourcePAT, GitHubHost: "github.com", + Login: "octocat", Status: ConnectionStatusActive, CredentialGeneration: 3, + }); err != nil { + t.Fatal(err) + } + next := *expected + next.Status = ConnectionStatusSuspended + next.CredentialGeneration++ + updated, err := store.TransitionWorkspaceInstallationConnection(ctx, expected, &next) + if err != nil { + t.Fatal(err) + } + connection, err := store.GetWorkspaceConnection(ctx, "ws-1") + if err != nil { + t.Fatal(err) + } + if updated || connection == nil || connection.Source != ConnectionSourcePAT || + connection.Login != "octocat" || connection.CredentialGeneration != 3 { + t.Fatalf("updated = %v, connection = %+v", updated, connection) + } +} + +func TestStoreUserConnectionRoundTrip(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-1") + seedStoreAppRegistration(t, store) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + refreshExpiry := now.Add(30 * 24 * time.Hour) + connection := &UserConnection{ + WorkspaceID: "ws-1", + UserID: "default-user", + AppRegistrationID: "registration-store-test", + GitHubUserID: 42, + Login: "octocat", + Status: ConnectionStatusActive, + AccessExpiresAt: now.Add(time.Hour), + RefreshExpiresAt: &refreshExpiry, + CredentialGeneration: 2, + CreatedAt: now, + UpdatedAt: now, + } + installationID := int64(12345) + if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ + WorkspaceID: "ws-1", Source: ConnectionSourceGitHubAppInstallation, + GitHubHost: "github.com", InstallationID: &installationID, + InstallationAccountLogin: "acme", InstallationAccountType: "Organization", + AppRegistrationID: "registration-store-test", Status: ConnectionStatusActive, + }); err != nil { + t.Fatal(err) + } + + if err := store.UpsertUserConnection(ctx, connection); err != nil { + t.Fatalf("upsert user connection: %v", err) + } + got, err := store.GetUserConnection(ctx, "ws-1", "default-user") + if err != nil { + t.Fatalf("get user connection: %v", err) + } + if got == nil || got.GitHubUserID != 42 || got.Login != "octocat" || got.RefreshExpiresAt == nil { + t.Fatalf("user connection = %+v", got) + } + + if err := store.DeleteUserConnection(ctx, "ws-1", "default-user"); err != nil { + t.Fatalf("delete user connection: %v", err) + } + got, err = store.GetUserConnection(ctx, "ws-1", "default-user") + if err != nil || got != nil { + t.Fatalf("user connection after delete = %+v, err %v", got, err) + } +} + +func TestStoreListsConnectionsByGitHubPrincipal(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-1", "ws-2", "ws-other") + seedStoreAppRegistration(t, store) + ctx := context.Background() + now := time.Now().UTC() + installationID := int64(12345) + otherInstallationID := int64(99999) + for workspaceID, appInstallationID := range map[string]*int64{ + "ws-1": &installationID, "ws-2": &installationID, "ws-other": &otherInstallationID, + } { + if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ + WorkspaceID: workspaceID, Source: ConnectionSourceGitHubAppInstallation, + GitHubHost: "github.com", InstallationID: appInstallationID, + InstallationAccountLogin: "acme", InstallationAccountType: "Organization", + AppRegistrationID: "registration-store-test", + Status: ConnectionStatusActive, CreatedAt: now, + }); err != nil { + t.Fatalf("seed workspace connection %s: %v", workspaceID, err) + } + } + for workspaceID, githubUserID := range map[string]int64{"ws-1": 42, "ws-2": 42, "ws-other": 99} { + if err := store.UpsertUserConnection(ctx, &UserConnection{ + WorkspaceID: workspaceID, UserID: "default-user", GitHubUserID: githubUserID, + AppRegistrationID: "registration-store-test", + Login: "octocat", Status: ConnectionStatusActive, + AccessExpiresAt: now.Add(time.Hour), CreatedAt: now, + }); err != nil { + t.Fatalf("seed user connection %s: %v", workspaceID, err) + } + } + + workspaces, err := store.ListWorkspaceConnectionsByInstallation(ctx, installationID) + if err != nil { + t.Fatalf("list workspaces by installation: %v", err) + } + if len(workspaces) != 2 || workspaces[0].WorkspaceID != "ws-1" || workspaces[1].WorkspaceID != "ws-2" { + t.Fatalf("workspaces by installation = %+v", workspaces) + } + users, err := store.ListUserConnectionsByGitHubUser(ctx, 42) + if err != nil { + t.Fatalf("list connections by GitHub user: %v", err) + } + if len(users) != 2 || users[0].WorkspaceID != "ws-1" || users[1].WorkspaceID != "ws-2" { + t.Fatalf("users by GitHub principal = %+v", users) + } +} + +func TestStoreConnectionConstraints(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-invalid-source", "ws-invalid-app", "ws-invalid-status") + ctx := context.Background() + now := time.Now().UTC() + + for _, connection := range []*WorkspaceConnection{ + {WorkspaceID: "ws-invalid-source", Source: "oauth", GitHubHost: "github.com", Status: ConnectionStatusActive, CreatedAt: now, UpdatedAt: now}, + {WorkspaceID: "ws-invalid-app", Source: ConnectionSourceGitHubAppInstallation, GitHubHost: "github.com", Status: ConnectionStatusActive, CreatedAt: now, UpdatedAt: now}, + {WorkspaceID: "ws-invalid-status", Source: ConnectionSourcePAT, GitHubHost: "github.com", Status: "connected", CreatedAt: now, UpdatedAt: now}, + } { + if err := store.UpsertWorkspaceConnection(ctx, connection); err == nil { + t.Fatalf("expected invalid connection to fail: %+v", connection) + } + } +} + +func TestStoreLegacyMigrationSeedsExistingWorkspacesAndBackfillsOwnership(t *testing.T) { + db := openLegacyGitHubDB(t) + ctx := context.Background() + if _, err := db.ExecContext(ctx, ` + INSERT INTO workspaces (id) VALUES ('ws-1'), ('ws-2'); + INSERT INTO tasks (id, workspace_id) VALUES ('task-1', 'ws-1'), ('task-2', 'ws-2'); + INSERT INTO github_pr_watches ( + id, session_id, task_id, repository_id, owner, repo, pr_number, branch, created_at, updated_at + ) VALUES + ('watch-owned', 'session-1', 'task-1', '', 'acme', 'repo', 1, 'main', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('watch-orphan', 'session-x', 'missing-task', '', 'acme', 'repo', 2, 'other', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + INSERT INTO github_task_prs ( + id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, head_branch, + base_branch, author_login, created_at, updated_at + ) VALUES + ('pr-owned', 'task-2', '', 'acme', 'repo', 3, 'url', 'title', 'head', 'main', 'octocat', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('pr-orphan', 'missing-task', '', 'acme', 'repo', 4, 'url', 'title', 'head', 'main', 'octocat', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + INSERT INTO github_review_watches ( + id, workspace_id, workflow_id, workflow_step_id, repos, agent_profile_id, + executor_profile_id, created_at, updated_at + ) VALUES ('review-watch-1', 'ws-1', 'workflow-1', 'step-1', '[]', 'agent-1', 'executor-1', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `); err != nil { + t.Fatalf("seed legacy rows: %v", err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("migrate legacy store: %v", err) + } + for _, workspaceID := range []string{"ws-1", "ws-2"} { + got, getErr := store.GetWorkspaceConnection(ctx, workspaceID) + if getErr != nil { + t.Fatalf("get legacy connection %s: %v", workspaceID, getErr) + } + if got == nil || got.Source != ConnectionSourceLegacyShared || got.Status != ConnectionStatusActive { + t.Fatalf("legacy connection %s = %+v", workspaceID, got) + } + } + + assertStoredWorkspaceID(t, db, "github_pr_watches", "watch-owned", "ws-1") + assertStoredWorkspaceID(t, db, "github_task_prs", "pr-owned", "ws-2") + assertStoredWorkspaceID(t, db, "github_pr_watches", "watch-orphan", "") + assertStoredWorkspaceID(t, db, "github_task_prs", "pr-orphan", "") + var targetLogin string + if err := db.GetContext(ctx, &targetLogin, + `SELECT target_login FROM github_review_watches WHERE id = 'review-watch-1'`); err != nil { + t.Fatalf("read migrated review target login: %v", err) + } + if targetLogin != "" { + t.Fatalf("migrated review target login = %q, want empty", targetLogin) + } + for _, indexName := range []string{ + "idx_github_task_prs_workspace", "idx_github_pr_watches_workspace", + } { + var indexCount int + if err := db.GetContext(ctx, &indexCount, + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, indexName); err != nil { + t.Fatalf("read ownership index %s: %v", indexName, err) + } + if indexCount != 1 { + t.Fatalf("ownership index %s count = %d, want 1", indexName, indexCount) + } + } + + if _, err := NewStore(db, db); err != nil { + t.Fatalf("replay migrated schema: %v", err) + } + var count int + if err := db.GetContext(ctx, &count, `SELECT COUNT(*) FROM github_workspace_connections`); err != nil { + t.Fatalf("count replayed connections: %v", err) + } + if count != 2 { + t.Fatalf("connection count after replay = %d, want 2", count) + } +} + +func TestStoreFreshSchemaDoesNotCreateLegacyConnection(t *testing.T) { + store := newTestStore(t) + got, err := store.GetWorkspaceConnection(context.Background(), "ws-new") + if err != nil { + t.Fatalf("get fresh connection: %v", err) + } + if got != nil { + t.Fatalf("fresh workspace inherited connection: %+v", got) + } +} + +func TestStorePersistsWorkspaceOwnershipOnNewPRRecords(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + watch := &PRWatch{ + WorkspaceID: "ws-1", SessionID: "session-1", TaskID: "task-1", + Owner: "acme", Repo: "repo", Branch: "feature", + } + if err := store.CreatePRWatch(ctx, watch); err != nil { + t.Fatalf("create PR watch: %v", err) + } + gotWatch, err := store.GetPRWatchBySession(ctx, "session-1") + if err != nil || gotWatch == nil || gotWatch.WorkspaceID != "ws-1" { + t.Fatalf("stored PR watch = %+v, err %v", gotWatch, err) + } + pr := &TaskPR{ + WorkspaceID: "ws-1", TaskID: "task-1", Owner: "acme", Repo: "repo", PRNumber: 7, + PRURL: "url", PRTitle: "title", HeadBranch: "feature", BaseBranch: "main", + CreatedAt: time.Now().UTC(), + } + if err := store.CreateTaskPR(ctx, pr); err != nil { + t.Fatalf("create task PR: %v", err) + } + gotPR, err := store.GetTaskPR(ctx, "task-1") + if err != nil || gotPR == nil || gotPR.WorkspaceID != "ws-1" { + t.Fatalf("stored task PR = %+v, err %v", gotPR, err) + } +} + +func TestStoreDeleteWorkspaceAuthData(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-1") + seedStoreAppRegistration(t, store) + ctx := context.Background() + now := time.Now().UTC() + installationID := int64(42) + if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ + WorkspaceID: "ws-1", Source: ConnectionSourceGitHubAppInstallation, GitHubHost: "github.com", + InstallationID: &installationID, InstallationAccountLogin: "acme", + InstallationAccountType: "Organization", AppRegistrationID: "registration-store-test", + Status: ConnectionStatusActive, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed workspace connection: %v", err) + } + if err := store.UpsertUserConnection(ctx, &UserConnection{ + WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: "registration-store-test", + GitHubUserID: 1, Login: "octocat", + Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed user connection: %v", err) + } + if err := store.CreateDeploymentAppRegistrationFlow(ctx, &DeploymentAppRegistrationFlow{ + StateHash: "registration-state", RegistrationID: "pending-registration", WorkspaceID: "ws-1", + UserID: "user-1", OwnerType: AppRegistrationOwnerOrganization, OwnerLogin: "acme", + DisplayName: "Pending", Visibility: AppRegistrationVisibilityPrivate, + PublicBaseURL: "https://kandev.example.com", ManifestRevision: 1, + ExpiresAt: now.Add(time.Hour), CreatedAt: now, + }); err != nil { + t.Fatalf("seed registration flow: %v", err) + } + + if err := store.DeleteWorkspaceAuthData(ctx, "ws-1"); err != nil { + t.Fatalf("delete workspace auth data: %v", err) + } + workspace, err := store.GetWorkspaceConnection(ctx, "ws-1") + if err != nil || workspace != nil { + t.Fatalf("workspace connection after delete = %+v, err %v", workspace, err) + } + user, err := store.GetUserConnection(ctx, "ws-1", "user-1") + if err != nil || user != nil { + t.Fatalf("user connection after delete = %+v, err %v", user, err) + } + flow, err := store.GetDeploymentAppRegistrationFlow(ctx, "registration-state") + if err != nil || flow != nil { + t.Fatalf("registration flow after delete = %+v, err %v", flow, err) + } +} + +func TestGitHubSecretKeysAreWorkspaceAndUserScoped(t *testing.T) { + if got, want := WorkspacePATSecretKey("ws-1"), "github:workspace:ws-1:pat"; got != want { + t.Fatalf("workspace PAT key = %q, want %q", got, want) + } + if got, want := UserAccessTokenSecretKey("ws-1", "user-1"), "github:user:ws-1:user-1:access"; got != want { + t.Fatalf("user access key = %q, want %q", got, want) + } + if got, want := UserRefreshTokenSecretKey("ws-1", "user-1"), "github:user:ws-1:user-1:refresh"; got != want { + t.Fatalf("user refresh key = %q, want %q", got, want) + } +} + +func TestStoreAuthFlowIsSingleUse(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-1") + seedStoreAppRegistration(t, store) + ctx := context.Background() + now := time.Now().UTC() + if err := store.CreateAuthFlow(ctx, &AuthFlow{ + StateHash: "state-hash", WorkspaceID: "ws-1", UserID: "user-1", + AppRegistrationID: "registration-store-test", + Kind: AuthFlowKindPersonal, PKCEVerifier: "verifier", ExpiresAt: now.Add(time.Minute), + }); err != nil { + t.Fatalf("create auth flow: %v", err) + } + flow, err := store.ConsumeAuthFlow( + ctx, "state-hash", "registration-store-test", AuthFlowKindPersonal, now, + ) + if err != nil || flow == nil || flow.ConsumedAt == nil { + t.Fatalf("consume auth flow = %+v, err %v", flow, err) + } + if _, err := store.ConsumeAuthFlow( + ctx, "state-hash", "registration-store-test", AuthFlowKindPersonal, now, + ); !errors.Is(err, ErrAuthFlowUnavailable) { + t.Fatalf("reconsume auth flow error = %v, want %v", err, ErrAuthFlowUnavailable) + } +} + +func TestStoreMigratesAndPersistsAuthFlowExpectations(t *testing.T) { + dbConn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "auth-flow-migration.db")) + if err != nil { + t.Fatal(err) + } + database := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = database.Close() }) + if _, err := database.Exec(` + CREATE TABLE workspaces (id TEXT PRIMARY KEY); + CREATE TABLE tasks (id TEXT PRIMARY KEY, workspace_id TEXT); + CREATE TABLE github_auth_flows ( + state_hash TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, user_id TEXT NOT NULL, + kind TEXT NOT NULL, pkce_verifier TEXT NOT NULL DEFAULT '', expires_at TIMESTAMP NOT NULL, + consumed_at TIMESTAMP, created_at TIMESTAMP NOT NULL + ); + INSERT INTO workspaces (id) VALUES ('ws-1'); + `); err != nil { + t.Fatal(err) + } + store, err := NewStore(database, database) + if err != nil { + t.Fatal(err) + } + seedStoreAppRegistration(t, store) + installationID := int64(42) + flow := &AuthFlow{ + StateHash: "state", WorkspaceID: "ws-1", UserID: "user-1", Kind: AuthFlowKindPersonal, + AppRegistrationID: "registration-store-test", + ExpectedWorkspaceSource: ConnectionSourceGitHubAppInstallation, + ExpectedWorkspaceGeneration: 3, ExpectedInstallationID: &installationID, + ExpectedPersonalGeneration: 7, ExpiresAt: time.Now().UTC().Add(time.Minute), + } + if err := store.CreateAuthFlow(context.Background(), flow); err != nil { + t.Fatal(err) + } + stored, err := store.GetAuthFlow(context.Background(), flow.StateHash) + if err != nil { + t.Fatal(err) + } + if stored.ExpectedWorkspaceSource != ConnectionSourceGitHubAppInstallation || + stored.ExpectedWorkspaceGeneration != 3 || stored.ExpectedInstallationID == nil || + *stored.ExpectedInstallationID != 42 || stored.ExpectedPersonalGeneration != 7 { + t.Fatalf("stored flow = %+v", stored) + } +} + +func TestStoreWebhookDeliveryDeduplicates(t *testing.T) { + store := newTestStore(t) + seedStoreAppRegistration(t, store) + ctx := context.Background() + delivery := &WebhookDelivery{ + AppRegistrationID: "registration-store-test", DeliveryID: "delivery-1", Event: "installation", + } + inserted, err := store.RecordWebhookDelivery(ctx, delivery) + if err != nil || !inserted { + t.Fatalf("record first delivery: inserted %v, err %v", inserted, err) + } + inserted, err = store.RecordWebhookDelivery(ctx, delivery) + if err != nil || inserted { + t.Fatalf("record duplicate delivery: inserted %v, err %v", inserted, err) + } +} + +func TestStoreWebhookDeliveryClaimRetriesOnlyFailedOrStale(t *testing.T) { + store := newTestStore(t) + seedStoreAppRegistration(t, store) + ctx := context.Background() + now := time.Now().UTC() + delivery := &WebhookDelivery{ + AppRegistrationID: "registration-store-test", DeliveryID: "delivery-claim", + Event: "installation", Status: WebhookDeliveryStatusReceived, + ReceivedAt: now, + } + claim, err := store.ClaimWebhookDelivery(ctx, delivery, now.Add(-time.Minute)) + if err != nil || !claim.Acquired { + t.Fatalf("first claim = %+v, err %v", claim, err) + } + claim, err = store.ClaimWebhookDelivery(ctx, delivery, now.Add(-time.Minute)) + if err != nil || claim.Acquired || claim.Status != WebhookDeliveryStatusReceived { + t.Fatalf("fresh duplicate claim = %+v, err %v", claim, err) + } + if err := store.CompleteWebhookDelivery( + ctx, delivery.DeliveryID, WebhookDeliveryStatusFailed, "temporary", now, + ); err != nil { + t.Fatal(err) + } + claim, err = store.ClaimWebhookDelivery(ctx, delivery, now.Add(-time.Minute)) + if err != nil || !claim.Acquired { + t.Fatalf("failed delivery retry claim = %+v, err %v", claim, err) + } + old := now.Add(-2 * time.Minute) + if _, err := store.db.ExecContext(ctx, store.db.Rebind(` + UPDATE github_webhook_deliveries SET received_at = ? WHERE delivery_id = ?`), old, delivery.DeliveryID); err != nil { + t.Fatal(err) + } + claim, err = store.ClaimWebhookDelivery(ctx, delivery, now.Add(-time.Minute)) + if err != nil || !claim.Acquired { + t.Fatalf("stale delivery retry claim = %+v, err %v", claim, err) + } +} + +func TestStoreReviewWatchTargetLoginRoundTrip(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + watch := &ReviewWatch{ + WorkspaceID: "ws-1", WorkflowID: "workflow-1", WorkflowStepID: "step-1", + AgentProfileID: "agent-1", ExecutorProfileID: "executor-1", + ReviewScope: "user_and_teams", TargetLogin: "octocat", Enabled: true, + PollIntervalSeconds: 300, + } + if err := store.CreateReviewWatch(ctx, watch); err != nil { + t.Fatalf("create review watch: %v", err) + } + got, err := store.GetReviewWatch(ctx, watch.ID) + if err != nil || got == nil || got.TargetLogin != "octocat" { + t.Fatalf("review watch after create = %+v, err %v", got, err) + } + got.TargetLogin = "hubot" + if err := store.UpdateReviewWatch(ctx, got); err != nil { + t.Fatalf("update review watch: %v", err) + } + got, err = store.GetReviewWatch(ctx, watch.ID) + if err != nil || got == nil || got.TargetLogin != "hubot" { + t.Fatalf("review watch after update = %+v, err %v", got, err) + } +} + +func seedConnectionWorkspaces(t *testing.T, store *Store, workspaceIDs ...string) { + t.Helper() + if _, err := store.db.Exec(`CREATE TABLE IF NOT EXISTS workspaces (id TEXT PRIMARY KEY)`); err != nil { + t.Fatalf("create workspaces table: %v", err) + } + for _, workspaceID := range workspaceIDs { + if _, err := store.db.Exec(`INSERT INTO workspaces (id) VALUES (?)`, workspaceID); err != nil { + t.Fatalf("seed workspace %s: %v", workspaceID, err) + } + } +} + +func TestStoreWorkspaceConnectionHealthIncludesDisconnectedWorkspaces(t *testing.T) { + store := newTestStore(t) + seedConnectionWorkspaces(t, store, "ws-active", "ws-invalid", "ws-suspended", "ws-disconnected") + seedStoreAppRegistration(t, store) + ctx := context.Background() + installationID := int64(42) + for _, connection := range []*WorkspaceConnection{ + {WorkspaceID: "ws-active", Source: ConnectionSourcePAT, GitHubHost: defaultGitHubHost, Login: "active", Status: ConnectionStatusActive}, + {WorkspaceID: "ws-invalid", Source: ConnectionSourcePAT, GitHubHost: defaultGitHubHost, Login: "invalid", Status: ConnectionStatusInvalid}, + {WorkspaceID: "ws-suspended", Source: ConnectionSourceGitHubAppInstallation, GitHubHost: defaultGitHubHost, + InstallationID: &installationID, InstallationAccountLogin: "acme", InstallationAccountType: "Organization", + AppRegistrationID: "registration-store-test", + Status: ConnectionStatusSuspended}, + } { + if err := store.UpsertWorkspaceConnection(ctx, connection); err != nil { + t.Fatalf("UpsertWorkspaceConnection(%s): %v", connection.WorkspaceID, err) + } + } + health, err := store.GetWorkspaceConnectionHealth(ctx) + if err != nil { + t.Fatalf("GetWorkspaceConnectionHealth() error = %v", err) + } + if health.WorkspaceCount != 4 || health.Active != 1 || health.Invalid != 1 || + health.Suspended != 1 || health.Disconnected != 1 || health.Revoked != 0 { + t.Fatalf("GetWorkspaceConnectionHealth() = %+v", health) + } +} + +func seedStoreAppRegistration(t *testing.T, store *Store) { + t.Helper() + registration := newAppRegistration( + "registration-store-test", 654, "Store test", time.Now().UTC(), + ) + if err := store.UpsertDeploymentAppRegistration(context.Background(), registration); err != nil { + t.Fatalf("seed Store App registration: %v", err) + } +} + +func openLegacyGitHubDB(t *testing.T) *sqlx.DB { + t.Helper() + dbConn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "legacy-github.db")) + if err != nil { + t.Fatalf("open legacy db: %v", err) + } + db := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + _, err = db.Exec(` + CREATE TABLE workspaces (id TEXT PRIMARY KEY); + CREATE TABLE tasks (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL); + CREATE TABLE github_workspace_settings (workspace_id TEXT PRIMARY KEY); + CREATE TABLE github_review_watches ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workflow_id TEXT NOT NULL, + workflow_step_id TEXT NOT NULL, repos TEXT NOT NULL DEFAULT '[]', agent_profile_id TEXT NOT NULL, + executor_profile_id TEXT NOT NULL, prompt TEXT DEFAULT '', review_scope TEXT NOT NULL DEFAULT 'user_and_teams', + custom_query TEXT NOT NULL DEFAULT '', enabled BOOLEAN DEFAULT 1, poll_interval_seconds INTEGER DEFAULT 300, + cleanup_policy TEXT NOT NULL DEFAULT 'auto', last_polled_at DATETIME, last_error TEXT NOT NULL DEFAULT '', + last_error_at DATETIME, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL + ); + CREATE TABLE github_pr_watches ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, task_id TEXT NOT NULL, + repository_id TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL, repo TEXT NOT NULL, + pr_number INTEGER NOT NULL, branch TEXT NOT NULL, last_checked_at DATETIME, + last_comment_at DATETIME, last_check_status TEXT DEFAULT '', last_review_state TEXT DEFAULT '', + created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, + UNIQUE(session_id, repository_id, branch) + ); + CREATE TABLE github_task_prs ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, repository_id TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL, repo TEXT NOT NULL, pr_number INTEGER NOT NULL, pr_url TEXT NOT NULL, + pr_title TEXT NOT NULL, head_branch TEXT NOT NULL, base_branch TEXT NOT NULL, + author_login TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'open', review_state TEXT NOT NULL DEFAULT '', + checks_state TEXT NOT NULL DEFAULT '', mergeable_state TEXT NOT NULL DEFAULT '', review_count INTEGER DEFAULT 0, + pending_review_count INTEGER DEFAULT 0, required_reviews INTEGER, comment_count INTEGER DEFAULT 0, + unresolved_review_threads INTEGER DEFAULT 0, checks_total INTEGER DEFAULT 0, checks_passing INTEGER DEFAULT 0, + additions INTEGER DEFAULT 0, deletions INTEGER DEFAULT 0, created_at DATETIME NOT NULL, + merged_at DATETIME, closed_at DATETIME, last_synced_at DATETIME, updated_at DATETIME NOT NULL, + UNIQUE(task_id, repository_id, pr_number) + ) + `) + if err != nil { + t.Fatalf("create legacy schema: %v", err) + } + return db +} + +func assertStoredWorkspaceID(t *testing.T, db *sqlx.DB, table, id, want string) { + t.Helper() + var got string + err := db.QueryRow(`SELECT workspace_id FROM `+table+` WHERE id = ?`, id).Scan(&got) + if err != nil { + t.Fatalf("read %s workspace_id: %v", table, err) + } + if got != want { + t.Fatalf("%s %s workspace_id = %q, want %q", table, id, got, want) + } +} diff --git a/apps/backend/internal/github/token_client.go b/apps/backend/internal/github/token_client.go new file mode 100644 index 0000000000..eac5495781 --- /dev/null +++ b/apps/backend/internal/github/token_client.go @@ -0,0 +1,73 @@ +package github + +import ( + "fmt" + "net/http" + "time" +) + +// TokenCredentialKind identifies the authority represented by a token. It is +// intentionally separate from the transport so callers retain attribution and +// routing information after a REST/GraphQL client is constructed. +type TokenCredentialKind string + +const ( + TokenCredentialPAT TokenCredentialKind = "pat" + TokenCredentialCLI TokenCredentialKind = "gh_cli" + TokenCredentialInstallation TokenCredentialKind = "github_app_installation" + TokenCredentialUser TokenCredentialKind = "github_app_user" +) + +// TokenPrincipal describes the GitHub actor behind a bearer token. +type TokenPrincipal struct { + Kind TokenCredentialKind `json:"kind"` + PrincipalID string `json:"principal_id"` + Login string `json:"login,omitempty"` + InstallationID int64 `json:"installation_id,omitempty"` + AppRegistrationID string `json:"app_registration_id,omitempty"` + AppCredentialGeneration int64 `json:"app_credential_generation,omitempty"` +} + +// TokenClient implements GitHub REST and GraphQL operations for any bearer +// token source. The token itself remains private while principal metadata is +// available for authorization, rate-limit, cache, and attribution keys. +type TokenClient struct { + token string + httpClient *http.Client + username string // cached after first GetAuthenticatedUser call + rateTracker *RateTracker + principal TokenPrincipal +} + +// PATClient remains an alias so existing callers retain source compatibility. +type PATClient = TokenClient + +// NewTokenClient constructs a GitHub client for a token and explicit actor. +func NewTokenClient(token string, principal TokenPrincipal) *TokenClient { + return &TokenClient{ + token: token, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + principal: principal, + } +} + +// NewPATClient creates a PAT-based GitHub client. +func NewPATClient(token string) *PATClient { + return NewTokenClient(token, TokenPrincipal{Kind: TokenCredentialPAT}) +} + +// NewAppUserTokenClient creates a client for a GitHub App user access token. +func NewAppUserTokenClient(token string, githubUserID int64, login string) *TokenClient { + return NewTokenClient(token, TokenPrincipal{ + Kind: TokenCredentialUser, + PrincipalID: fmt.Sprintf("user:%d", githubUserID), + Login: login, + }) +} + +// Principal returns the actor metadata attached at construction. +func (c *TokenClient) Principal() TokenPrincipal { + return c.principal +} diff --git a/apps/backend/internal/github/token_client_test.go b/apps/backend/internal/github/token_client_test.go new file mode 100644 index 0000000000..a25f35338d --- /dev/null +++ b/apps/backend/internal/github/token_client_test.go @@ -0,0 +1,33 @@ +package github + +import "testing" + +func TestTokenClient_PreservesPrincipalMetadata(t *testing.T) { + principal := TokenPrincipal{ + Kind: TokenCredentialInstallation, + PrincipalID: "installation:42", + Login: "acme", + InstallationID: 42, + } + client := NewTokenClient("token", principal) + if got := client.Principal(); got != principal { + t.Fatalf("Principal() = %+v, want %+v", got, principal) + } +} + +func TestTokenClient_PATCompatibility(t *testing.T) { + client := NewPATClient("pat") + if got := client.Principal().Kind; got != TokenCredentialPAT { + t.Fatalf("PAT principal kind = %q, want %q", got, TokenCredentialPAT) + } + var _ Client = client + var _ GraphQLExecutor = client +} + +func TestTokenClient_AppUserConstructor(t *testing.T) { + client := NewAppUserTokenClient("ghu_token", 123, "octocat") + principal := client.Principal() + if principal.Kind != TokenCredentialUser || principal.PrincipalID != "user:123" || principal.Login != "octocat" { + t.Fatalf("App user principal = %+v", principal) + } +} diff --git a/apps/backend/internal/github/webhook_service.go b/apps/backend/internal/github/webhook_service.go new file mode 100644 index 0000000000..9f3a34cc9a --- /dev/null +++ b/apps/backend/internal/github/webhook_service.go @@ -0,0 +1,539 @@ +package github + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "go.uber.org/zap" +) + +const ( + maxGitHubWebhookPayloadSize = 10 * 1024 * 1024 + webhookDeliveryStaleAfter = 5 * time.Minute +) + +var ErrInvalidWebhookSignature = errors.New("invalid GitHub webhook signature") + +type githubWebhookStore interface { + ClaimWebhookDelivery(context.Context, *WebhookDelivery, time.Time) (WebhookDeliveryClaim, error) + CompleteAppRegistrationWebhookDelivery( + context.Context, string, string, WebhookDeliveryStatus, string, time.Time, + ) error + ListWorkspaceConnectionsByAppInstallation(context.Context, string, int64) ([]*WorkspaceConnection, error) + TransitionWorkspaceInstallationConnection( + context.Context, + *WorkspaceConnection, + *WorkspaceConnection, + ) (bool, error) + ListUserConnectionsByAppGitHubUser(context.Context, string, int64) ([]*UserConnection, error) +} + +type personalConnectionRevoker interface { + RevokePersonalConnection(context.Context, string, string) error +} + +type personalAuthorizationReconciler interface { + ReconcileAuthorizationRevocation(context.Context, *UserConnection) (bool, error) +} + +type GitHubWebhookReconciliation struct { + Installations appInstallationVerifier + Personal personalAuthorizationReconciler +} + +type InstallationRepository struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` +} + +type InstallationRepositoriesChange struct { + WorkspaceID string + AppRegistrationID string + InstallationID int64 + ConnectionSource ConnectionSource + CredentialGeneration int64 + Action string + Added []InstallationRepository + Removed []InstallationRepository +} + +type installationRepositoryUpdater interface { + ApplyInstallationRepositories(context.Context, InstallationRepositoriesChange) (bool, error) +} + +type GitHubWebhookRequest struct { + DeliveryID string + Event string + Signature string + Payload []byte +} + +func (s *Service) HandleAppRegistrationWebhook( + ctx context.Context, + registrationID string, + request GitHubWebhookRequest, +) (GitHubWebhookResult, error) { + registrationID = strings.TrimSpace(registrationID) + runtime := s.currentAppRegistrationRuntime(registrationID) + if registrationID == "" || runtime == nil || runtime.webhookAuth == nil || + runtime.webhookAuth.registrationID != registrationID { + return GitHubWebhookResult{}, ErrGitHubNotConfigured + } + service := runtime.webhookAuth + signed := service.Authenticates(request) + result, err := service.Handle(ctx, request) + if !signed { + return result, err + } + status := DeploymentAppWebhookVerified + reason := "" + if err != nil { + status = DeploymentAppWebhookFailing + reason = "signed webhook processing failed" + } + observedAt := service.now().UTC() + if s.store != nil { + if _, healthErr := s.store.updateAppRegistrationWebhookHealth( + ctx, registrationID, runtime.generation, status, observedAt, reason, + ); healthErr != nil && s.logger != nil { + s.logger.Warn("App registration webhook health persistence failed", zap.Error(healthErr)) + } + } + return result, err +} + +type GitHubWebhookResult struct { + Duplicate bool + Status WebhookDeliveryStatus + Affected int +} + +type installationWebhookEvent struct { + Action string `json:"action"` + Installation struct { + ID int64 `json:"id"` + Account struct { + Login string `json:"login"` + Type string `json:"type"` + } `json:"account"` + } `json:"installation"` +} + +type installationTransition struct { + status ConnectionStatus + lastError string + login string + accountType string + verified bool +} + +type GitHubWebhookService struct { + registrationID string + secret []byte + store githubWebhookStore + repos installationRepositoryUpdater + personal personalConnectionRevoker + installations appInstallationVerifier + personalReconciler personalAuthorizationReconciler + now func() time.Time +} + +func NewGitHubWebhookService( + secret string, + store githubWebhookStore, + repositories installationRepositoryUpdater, + personal personalConnectionRevoker, + reconciliation ...GitHubWebhookReconciliation, +) *GitHubWebhookService { + service := &GitHubWebhookService{ + secret: []byte(secret), store: store, repos: repositories, personal: personal, now: time.Now, + } + if len(reconciliation) > 0 { + service.installations = reconciliation[0].Installations + service.personalReconciler = reconciliation[0].Personal + } + return service +} + +func NewAppRegistrationWebhookService( + registrationID, secret string, + store githubWebhookStore, + repositories installationRepositoryUpdater, + personal personalConnectionRevoker, + reconciliation ...GitHubWebhookReconciliation, +) *GitHubWebhookService { + service := NewGitHubWebhookService(secret, store, repositories, personal, reconciliation...) + service.registrationID = strings.TrimSpace(registrationID) + return service +} + +func (s *GitHubWebhookService) Authenticates(request GitHubWebhookRequest) bool { + return s != nil && len(s.secret) > 0 && request.DeliveryID != "" && request.Event != "" && + len(request.Payload) <= maxGitHubWebhookPayloadSize && + validGitHubWebhookSignature(s.secret, request.Payload, request.Signature) +} + +func (s *GitHubWebhookService) Handle( + ctx context.Context, + request GitHubWebhookRequest, +) (GitHubWebhookResult, error) { + if s == nil || s.store == nil || len(s.secret) == 0 { + return GitHubWebhookResult{}, errors.New("GitHub webhook service is not configured") + } + if request.DeliveryID == "" || request.Event == "" || len(request.Payload) > maxGitHubWebhookPayloadSize { + return GitHubWebhookResult{}, errors.New("invalid GitHub webhook request") + } + if !validGitHubWebhookSignature(s.secret, request.Payload, request.Signature) { + return GitHubWebhookResult{}, ErrInvalidWebhookSignature + } + now := s.now().UTC() + claim, err := s.store.ClaimWebhookDelivery(ctx, &WebhookDelivery{ + AppRegistrationID: s.registrationID, + DeliveryID: request.DeliveryID, + Event: request.Event, + Status: WebhookDeliveryStatusReceived, + ReceivedAt: now, + }, now.Add(-webhookDeliveryStaleAfter)) + if err != nil { + return GitHubWebhookResult{}, fmt.Errorf("record GitHub webhook delivery: %w", err) + } + if !claim.Acquired { + return GitHubWebhookResult{Duplicate: true, Status: claim.Status}, nil + } + + affected, result, processErr := s.process(ctx, request.Event, request.Payload) + status := WebhookDeliveryStatusProcessed + if affected == 0 { + status = WebhookDeliveryStatusIgnored + } + if processErr != nil { + status = WebhookDeliveryStatusFailed + result = processErr.Error() + } + completeErr := s.completeDelivery(ctx, request.DeliveryID, status, result, s.now().UTC()) + if processErr != nil || completeErr != nil { + return GitHubWebhookResult{Status: status, Affected: affected}, errors.Join(processErr, completeErr) + } + return GitHubWebhookResult{Status: status, Affected: affected}, nil +} + +func (s *GitHubWebhookService) completeDelivery( + ctx context.Context, + deliveryID string, + status WebhookDeliveryStatus, + result string, + processedAt time.Time, +) error { + return s.store.CompleteAppRegistrationWebhookDelivery( + ctx, s.registrationID, deliveryID, status, result, processedAt, + ) +} + +func (s *GitHubWebhookService) process(ctx context.Context, event string, payload []byte) (int, string, error) { + switch event { + case "installation": + return s.processInstallation(ctx, payload) + case "installation_repositories": + return s.processInstallationRepositories(ctx, payload) + case "github_app_authorization": + return s.processAuthorization(ctx, payload) + default: + return 0, "event ignored", nil + } +} + +func (s *GitHubWebhookService) processInstallation( + ctx context.Context, + payload []byte, +) (int, string, error) { + var event installationWebhookEvent + if err := json.Unmarshal(payload, &event); err != nil { + return 0, "", fmt.Errorf("decode installation webhook: %w", err) + } + transition, supported, err := s.installationTransition(ctx, event) + if err != nil { + return 0, "", err + } + if !supported || event.Installation.ID <= 0 { + return 0, "installation action ignored", nil + } + connections, err := s.listWorkspaceConnections(ctx, event.Installation.ID) + if err != nil { + return 0, "", fmt.Errorf("load installation bindings: %w", err) + } + affected := 0 + for _, connection := range connections { + if !shouldApplyInstallationTransition(s.registrationID, connection, event, transition) { + continue + } + updated, err := s.applyInstallationTransition(ctx, connection, transition) + if err != nil { + return affected, "", fmt.Errorf("update installation binding: %w", err) + } + if !updated { + continue + } + affected++ + } + return affected, fmt.Sprintf("installation %s: %d binding(s)", event.Action, affected), nil +} + +func (s *GitHubWebhookService) listWorkspaceConnections( + ctx context.Context, + installationID int64, +) ([]*WorkspaceConnection, error) { + return s.store.ListWorkspaceConnectionsByAppInstallation(ctx, s.registrationID, installationID) +} + +func (s *GitHubWebhookService) installationTransition( + ctx context.Context, + event installationWebhookEvent, +) (installationTransition, bool, error) { + status, lastError, supported := installationWebhookStatus(event.Action) + transition := installationTransition{ + status: status, lastError: lastError, + login: event.Installation.Account.Login, accountType: event.Installation.Account.Type, + } + if !supported || event.Installation.ID <= 0 || s.installations == nil { + return transition, supported, nil + } + status, lastError, login, accountType, err := s.reconcileInstallation(ctx, event.Installation.ID) + if err != nil { + return installationTransition{}, false, err + } + return installationTransition{ + status: status, lastError: lastError, login: login, accountType: accountType, verified: true, + }, true, nil +} + +func shouldApplyInstallationTransition( + registrationID string, + connection *WorkspaceConnection, + event installationWebhookEvent, + transition installationTransition, +) bool { + if !matchesInstallation(connection, registrationID, event.Installation.ID) { + return false + } + if !transition.verified { + return canApplyInstallationTransition(connection.Status, event.Action) + } + return connection.Status != transition.status || + (transition.login != "" && connection.InstallationAccountLogin != transition.login) || + (transition.accountType != "" && connection.InstallationAccountType != transition.accountType) +} + +func (s *GitHubWebhookService) applyInstallationTransition( + ctx context.Context, + connection *WorkspaceConnection, + transition installationTransition, +) (bool, error) { + expected := *connection + next := expected + next.Status = transition.status + next.LastError = transition.lastError + next.CredentialGeneration++ + if transition.login != "" { + next.InstallationAccountLogin = transition.login + } + if transition.accountType != "" { + next.InstallationAccountType = transition.accountType + } + updated, err := s.store.TransitionWorkspaceInstallationConnection(ctx, &expected, &next) + if updated { + *connection = next + } + return updated, err +} + +func (s *GitHubWebhookService) processInstallationRepositories( + ctx context.Context, + payload []byte, +) (int, string, error) { + var event struct { + Action string `json:"action"` + Installation struct { + ID int64 `json:"id"` + } `json:"installation"` + Added []InstallationRepository `json:"repositories_added"` + Removed []InstallationRepository `json:"repositories_removed"` + } + if err := json.Unmarshal(payload, &event); err != nil { + return 0, "", fmt.Errorf("decode installation repositories webhook: %w", err) + } + if event.Installation.ID <= 0 || (event.Action != "added" && event.Action != "removed") { + return 0, "installation repositories action ignored", nil + } + connections, err := s.listWorkspaceConnections(ctx, event.Installation.ID) + if err != nil { + return 0, "", fmt.Errorf("load installation bindings: %w", err) + } + affected := 0 + for _, connection := range connections { + if !matchesInstallation(connection, s.registrationID, event.Installation.ID) { + continue + } + if s.repos == nil { + return affected, "", errors.New("installation repository updater is not configured") + } + updated, err := s.repos.ApplyInstallationRepositories(ctx, InstallationRepositoriesChange{ + WorkspaceID: connection.WorkspaceID, + AppRegistrationID: s.registrationID, + InstallationID: event.Installation.ID, + ConnectionSource: connection.Source, + CredentialGeneration: connection.CredentialGeneration, + Action: event.Action, + Added: event.Added, + Removed: event.Removed, + }) + if err != nil { + return affected, "", fmt.Errorf("update installation repository access: %w", err) + } + if updated { + affected++ + } + } + return affected, fmt.Sprintf("installation repositories %s: %d binding(s)", event.Action, affected), nil +} + +func (s *GitHubWebhookService) processAuthorization( + ctx context.Context, + payload []byte, +) (int, string, error) { + var event struct { + Action string `json:"action"` + Sender struct { + ID int64 `json:"id"` + } `json:"sender"` + } + if err := json.Unmarshal(payload, &event); err != nil { + return 0, "", fmt.Errorf("decode GitHub App authorization webhook: %w", err) + } + if event.Action != "revoked" || event.Sender.ID <= 0 { + return 0, "authorization action ignored", nil + } + connections, err := s.store.ListUserConnectionsByAppGitHubUser(ctx, s.registrationID, event.Sender.ID) + if err != nil { + return 0, "", fmt.Errorf("load personal GitHub bindings: %w", err) + } + affected := 0 + for _, connection := range connections { + if connection == nil || connection.AppRegistrationID != s.registrationID || + connection.GitHubUserID != event.Sender.ID { + continue + } + if s.personalReconciler != nil { + revoked, reconcileErr := s.personalReconciler.ReconcileAuthorizationRevocation(ctx, connection) + if reconcileErr != nil { + return affected, "", fmt.Errorf("reconcile personal GitHub connection: %w", reconcileErr) + } + if revoked { + affected++ + } + continue + } + if s.personal == nil { + return affected, "", errors.New("personal GitHub revoker is not configured") + } + if err := s.personal.RevokePersonalConnection(ctx, connection.WorkspaceID, connection.UserID); err != nil { + return affected, "", fmt.Errorf("revoke personal GitHub connection: %w", err) + } + affected++ + } + return affected, fmt.Sprintf("authorization revoked: %d binding(s)", affected), nil +} + +func (s *GitHubWebhookService) reconcileInstallation( + ctx context.Context, + installationID int64, +) (ConnectionStatus, string, string, string, error) { + installation, err := s.installations.GetInstallation(ctx, installationID) + if err != nil { + var apiErr *GitHubAPIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 404 { + return ConnectionStatusRevoked, "GitHub App installation deleted", "", "", nil + } + return "", "", "", "", fmt.Errorf("reconcile GitHub App installation: %w", err) + } + if installation.ID != installationID { + return "", "", "", "", errors.New("reconciled GitHub App installation ID does not match webhook") + } + if installation.SuspendedAt != nil { + return ConnectionStatusSuspended, "GitHub App installation suspended", + installation.AccountLogin, installation.AccountType, nil + } + return ConnectionStatusActive, "", installation.AccountLogin, installation.AccountType, nil +} + +func validGitHubWebhookSignature(secret, payload []byte, provided string) bool { + if !strings.HasPrefix(provided, "sha256=") { + return false + } + providedMAC, err := hex.DecodeString(strings.TrimPrefix(provided, "sha256=")) + if err != nil { + return false + } + mac := hmac.New(sha256.New, secret) + _, _ = mac.Write(payload) + return hmac.Equal(mac.Sum(nil), providedMAC) +} + +func installationWebhookStatus(action string) (ConnectionStatus, string, bool) { + switch action { + case "suspend": + return ConnectionStatusSuspended, "GitHub App installation suspended", true + case "unsuspend": + return ConnectionStatusActive, "", true + case "deleted": + return ConnectionStatusRevoked, "GitHub App installation deleted", true + default: + return "", "", false + } +} + +func canApplyInstallationTransition(current ConnectionStatus, action string) bool { + switch action { + case "suspend": + return current != ConnectionStatusSuspended && current != ConnectionStatusRevoked + case "unsuspend": + return current == ConnectionStatusSuspended || current == ConnectionStatusInvalid + case "deleted": + return current != ConnectionStatusRevoked + default: + return false + } +} + +func matchesInstallation(connection *WorkspaceConnection, registrationID string, installationID int64) bool { + return connection != nil && connection.Source == ConnectionSourceGitHubAppInstallation && + connection.AppRegistrationID == registrationID && + connection.InstallationID != nil && *connection.InstallationID == installationID +} + +func (s *Store) updateAppRegistrationWebhookHealth( + ctx context.Context, + registrationID string, + generation int64, + status DeploymentAppWebhookStatus, + lastWebhookAt time.Time, + lastError string, +) (bool, error) { + result, err := s.db.ExecContext(ctx, s.db.Rebind(` + UPDATE github_app_registrations + SET webhook_status = ?, last_webhook_at = ?, last_error = ?, updated_at = ? + WHERE id = ? AND credential_generation = ?`), + status, lastWebhookAt, nullString(lastError), time.Now().UTC(), registrationID, generation, + ) + if err != nil { + return false, err + } + count, err := result.RowsAffected() + return count == 1, err +} diff --git a/apps/backend/internal/github/webhook_service_test.go b/apps/backend/internal/github/webhook_service_test.go new file mode 100644 index 0000000000..1a2b13e7a4 --- /dev/null +++ b/apps/backend/internal/github/webhook_service_test.go @@ -0,0 +1,711 @@ +package github + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "sync" + "testing" + "time" +) + +type githubWebhookMemoryStore struct { + workspaces map[int64][]*WorkspaceConnection + users map[int64][]*UserConnection + deliveries map[string]*WebhookDelivery + updates int + updateErr error + beforeTransition func() +} + +func (s *githubWebhookMemoryStore) ClaimWebhookDelivery( + _ context.Context, delivery *WebhookDelivery, staleBefore time.Time, +) (WebhookDeliveryClaim, error) { + if s.deliveries == nil { + s.deliveries = make(map[string]*WebhookDelivery) + } + key := delivery.AppRegistrationID + ":" + delivery.DeliveryID + existing, exists := s.deliveries[key] + if exists && existing.Status != WebhookDeliveryStatusFailed && + (existing.Status != WebhookDeliveryStatusReceived || existing.ReceivedAt.After(staleBefore)) { + return WebhookDeliveryClaim{Status: existing.Status}, nil + } + copy := *delivery + s.deliveries[key] = © + return WebhookDeliveryClaim{Acquired: true, Status: WebhookDeliveryStatusReceived}, nil +} + +func (s *githubWebhookMemoryStore) CompleteWebhookDelivery( + _ context.Context, deliveryID string, status WebhookDeliveryStatus, result string, processedAt time.Time, +) error { + var delivery *WebhookDelivery + for _, candidate := range s.deliveries { + if candidate.DeliveryID == deliveryID { + delivery = candidate + break + } + } + if delivery == nil { + return errors.New("delivery not found") + } + delivery.Status = status + delivery.Result = result + delivery.ProcessedAt = &processedAt + return nil +} + +func (s *githubWebhookMemoryStore) CompleteAppRegistrationWebhookDelivery( + _ context.Context, + registrationID, deliveryID string, + status WebhookDeliveryStatus, + result string, + processedAt time.Time, +) error { + delivery := s.deliveries[registrationID+":"+deliveryID] + if delivery == nil { + return errors.New("delivery not found") + } + delivery.Status = status + delivery.Result = result + delivery.ProcessedAt = &processedAt + return nil +} + +func (s *githubWebhookMemoryStore) ListWorkspaceConnectionsByInstallation( + _ context.Context, installationID int64, +) ([]*WorkspaceConnection, error) { + return s.workspaces[installationID], nil +} + +func (s *githubWebhookMemoryStore) ListWorkspaceConnectionsByAppInstallation( + _ context.Context, registrationID string, installationID int64, +) ([]*WorkspaceConnection, error) { + connections := s.workspaces[installationID] + matched := make([]*WorkspaceConnection, 0, len(connections)) + for _, connection := range connections { + if connection != nil && connection.AppRegistrationID == registrationID { + matched = append(matched, connection) + } + } + return matched, nil +} + +func (s *githubWebhookMemoryStore) TransitionWorkspaceInstallationConnection( + _ context.Context, + expected, next *WorkspaceConnection, +) (bool, error) { + if s.beforeTransition != nil { + s.beforeTransition() + s.beforeTransition = nil + } + if s.updateErr != nil { + err := s.updateErr + s.updateErr = nil + return false, err + } + for _, current := range s.workspaces[*expected.InstallationID] { + if current.WorkspaceID != expected.WorkspaceID { + continue + } + if current.Source != expected.Source || current.InstallationID == nil || + *current.InstallationID != *expected.InstallationID || + current.AppRegistrationID != expected.AppRegistrationID || + current.CredentialGeneration != expected.CredentialGeneration || current.Status != expected.Status { + return false, nil + } + *current = *next + s.updates++ + return true, nil + } + return false, nil +} + +func (s *githubWebhookMemoryStore) ListUserConnectionsByGitHubUser( + _ context.Context, githubUserID int64, +) ([]*UserConnection, error) { + return s.users[githubUserID], nil +} + +func (s *githubWebhookMemoryStore) ListUserConnectionsByAppGitHubUser( + _ context.Context, registrationID string, githubUserID int64, +) ([]*UserConnection, error) { + connections := s.users[githubUserID] + matched := make([]*UserConnection, 0, len(connections)) + for _, connection := range connections { + if connection != nil && connection.AppRegistrationID == registrationID { + matched = append(matched, connection) + } + } + return matched, nil +} + +type webhookPersonalRevoker struct { + revoked []string +} + +type webhookInstallationVerifier struct { + installation AppInstallation + err error +} + +func (v *webhookInstallationVerifier) GetInstallation( + context.Context, + int64, +) (AppInstallation, error) { + return v.installation, v.err +} + +type webhookPersonalReconciler struct { + revoked bool + calls int +} + +func (r *webhookPersonalReconciler) ReconcileAuthorizationRevocation( + context.Context, + *UserConnection, +) (bool, error) { + r.calls++ + return r.revoked, nil +} + +func (r *webhookPersonalRevoker) RevokePersonalConnection(_ context.Context, workspaceID, userID string) error { + r.revoked = append(r.revoked, workspaceID+":"+userID) + return nil +} + +type webhookRepoUpdater struct { + updates []InstallationRepositoriesChange + err error +} + +func newRegistrationWebhookService( + registrationID, secret string, + store githubWebhookStore, + repositories installationRepositoryUpdater, + personal personalConnectionRevoker, + reconciliation ...GitHubWebhookReconciliation, +) *GitHubWebhookService { + return NewAppRegistrationWebhookService( + registrationID, secret, store, repositories, personal, reconciliation..., + ) +} + +func TestGitHubWebhookDeliveryIdentityIncludesRegistration(t *testing.T) { + store := &githubWebhookMemoryStore{} + payload := []byte(`{}`) + request := signedWebhookRequest("shared-secret", "delivery-1", "ping", payload) + + for _, registrationID := range []string{"registration-a", "registration-b"} { + service := newRegistrationWebhookService(registrationID, "shared-secret", store, nil, nil) + result, err := service.Handle(context.Background(), request) + if err != nil || result.Duplicate { + t.Fatalf("registration %s Handle() = %+v, err %v", registrationID, result, err) + } + } + if len(store.deliveries) != 2 { + t.Fatalf("deliveries = %#v, want one per registration", store.deliveries) + } +} + +func TestGitHubWebhookOnlyMutatesMatchingRegistrationInstallation(t *testing.T) { + installationID := int64(42) + work := &WorkspaceConnection{ + WorkspaceID: "work", AppRegistrationID: "registration-work", + Source: ConnectionSourceGitHubAppInstallation, InstallationID: &installationID, + Status: ConnectionStatusActive, CredentialGeneration: 1, + } + personal := &WorkspaceConnection{ + WorkspaceID: "personal", AppRegistrationID: "registration-personal", + Source: ConnectionSourceGitHubAppInstallation, InstallationID: &installationID, + Status: ConnectionStatusActive, CredentialGeneration: 1, + } + store := &githubWebhookMemoryStore{workspaces: map[int64][]*WorkspaceConnection{ + installationID: {work, personal}, + }} + service := newRegistrationWebhookService("registration-work", "work-secret", store, nil, nil) + request := signedWebhookRequest( + "work-secret", "delivery-1", "installation", + []byte(`{"action":"suspend","installation":{"id":42}}`), + ) + + result, err := service.Handle(context.Background(), request) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + if result.Affected != 1 || work.Status != ConnectionStatusSuspended || + personal.Status != ConnectionStatusActive { + t.Fatalf("result=%+v work=%+v personal=%+v", result, work, personal) + } +} + +func (u *webhookRepoUpdater) ApplyInstallationRepositories( + _ context.Context, change InstallationRepositoriesChange, +) (bool, error) { + if u.err != nil { + err := u.err + u.err = nil + return false, err + } + u.updates = append(u.updates, change) + return true, nil +} + +func TestGitHubWebhookRejectsInvalidSignatureBeforeDedupe(t *testing.T) { + store := &githubWebhookMemoryStore{} + service := newRegistrationWebhookService("registration-b", "registration-b-secret", store, nil, nil) + wrongRouteRequest := signedWebhookRequest( + "registration-a-secret", "delivery-1", "installation", []byte(`{"action":`), + ) + _, err := service.Handle(context.Background(), wrongRouteRequest) + if !errors.Is(err, ErrInvalidWebhookSignature) || len(store.deliveries) != 0 { + t.Fatalf("Handle() error = %v, deliveries = %v", err, store.deliveries) + } +} + +func TestGitHubWebhookAuthorizationRevocationOnlyMatchesRegistration(t *testing.T) { + store := &githubWebhookMemoryStore{users: map[int64][]*UserConnection{11: { + {WorkspaceID: "work", UserID: "user-1", AppRegistrationID: "registration-work", GitHubUserID: 11}, + {WorkspaceID: "personal", UserID: "user-1", AppRegistrationID: "registration-personal", GitHubUserID: 11}, + }}} + revoker := &webhookPersonalRevoker{} + service := newRegistrationWebhookService("registration-work", "work-secret", store, nil, revoker) + request := signedWebhookRequest( + "work-secret", "delivery-authorization", "github_app_authorization", + []byte(`{"action":"revoked","sender":{"id":11}}`), + ) + + result, err := service.Handle(context.Background(), request) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + if result.Affected != 1 || len(revoker.revoked) != 1 || revoker.revoked[0] != "work:user-1" { + t.Fatalf("result=%+v revoked=%v", result, revoker.revoked) + } +} + +func TestAppRegistrationWebhookHealthUpdateIsRegistrationLocal(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + for _, registration := range []*AppRegistration{ + newAppRegistration("registration-work", 101, "Work", now), + newAppRegistration("registration-personal", 202, "Personal", now), + } { + if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil { + t.Fatal(err) + } + } + + updated, err := store.updateAppRegistrationWebhookHealth( + ctx, "registration-work", 1, DeploymentAppWebhookVerified, now, "", + ) + if err != nil || !updated { + t.Fatalf("update work health = %v, err %v", updated, err) + } + work, err := store.GetAppRegistration(ctx, "registration-work") + if err != nil || work.WebhookStatus != DeploymentAppWebhookVerified || work.LastWebhookAt == nil { + t.Fatalf("work registration = %+v, err %v", work, err) + } + personal, err := store.GetAppRegistration(ctx, "registration-personal") + if err != nil || personal.WebhookStatus != DeploymentAppWebhookUnverified || personal.LastWebhookAt != nil { + t.Fatalf("personal registration = %+v, err %v", personal, err) + } + + updated, err = store.updateAppRegistrationWebhookHealth( + ctx, "registration-work", 0, DeploymentAppWebhookFailing, now, "stale", + ) + if err != nil || updated { + t.Fatalf("stale generation update = %v, err %v", updated, err) + } +} + +func TestGitHubWebhookConcurrentDeliveryIsProcessedOncePerRegistration(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + seedConnectionWorkspaces(t, store, "workspace-1") + if err := store.UpsertDeploymentAppRegistration( + ctx, newAppRegistration("registration-work", 101, "Work", now), + ); err != nil { + t.Fatal(err) + } + installationID := int64(42) + if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ + WorkspaceID: "workspace-1", AppRegistrationID: "registration-work", + Source: ConnectionSourceGitHubAppInstallation, GitHubHost: "github.com", + InstallationID: &installationID, InstallationAccountLogin: "acme", + InstallationAccountType: "Organization", Status: ConnectionStatusActive, + CredentialGeneration: 1, + }); err != nil { + t.Fatal(err) + } + service := NewAppRegistrationWebhookService( + "registration-work", "work-secret", store, nil, nil, + ) + request := signedWebhookRequest( + "work-secret", "delivery-concurrent", "installation", + []byte(`{"action":"suspend","installation":{"id":42}}`), + ) + + type outcome struct { + result GitHubWebhookResult + err error + } + ready := sync.WaitGroup{} + ready.Add(2) + start := make(chan struct{}) + outcomes := make(chan outcome, 2) + for range 2 { + go func() { + ready.Done() + <-start + result, err := service.Handle(ctx, request) + outcomes <- outcome{result: result, err: err} + }() + } + ready.Wait() + close(start) + + affected, duplicates := 0, 0 + for range 2 { + outcome := <-outcomes + if outcome.err != nil { + t.Fatalf("Handle() error = %v", outcome.err) + } + affected += outcome.result.Affected + if outcome.result.Duplicate { + duplicates++ + } + } + if affected != 1 || duplicates != 1 { + t.Fatalf("affected=%d duplicates=%d", affected, duplicates) + } +} + +func TestGitHubWebhookDeduplicatesInstallationTransition(t *testing.T) { + installationID := int64(42) + connection := &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, Status: ConnectionStatusActive, CredentialGeneration: 2, + } + store := &githubWebhookMemoryStore{workspaces: map[int64][]*WorkspaceConnection{42: {connection}}} + service := NewGitHubWebhookService("webhook-secret", store, nil, nil) + payload := []byte(`{"action":"suspend","installation":{"id":42}}`) + request := signedWebhookRequest("webhook-secret", "delivery-1", "installation", payload) + + first, err := service.Handle(context.Background(), request) + if err != nil { + t.Fatalf("first Handle() error = %v", err) + } + second, err := service.Handle(context.Background(), request) + if err != nil { + t.Fatalf("duplicate Handle() error = %v", err) + } + if first.Affected != 1 || !second.Duplicate || store.updates != 1 || + connection.Status != ConnectionStatusSuspended || connection.CredentialGeneration != 3 { + t.Fatalf("first = %+v, second = %+v, connection = %+v, updates = %d", first, second, connection, store.updates) + } +} + +func TestGitHubWebhookUnsuspendsAndDeletesKnownInstallation(t *testing.T) { + installationID := int64(42) + connection := &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, Status: ConnectionStatusSuspended, CredentialGeneration: 3, + } + store := &githubWebhookMemoryStore{workspaces: map[int64][]*WorkspaceConnection{42: {connection}}} + service := NewGitHubWebhookService("webhook-secret", store, nil, nil) + + unsuspend := []byte(`{"action":"unsuspend","installation":{"id":42}}`) + if _, err := service.Handle( + context.Background(), signedWebhookRequest("webhook-secret", "delivery-unsuspend", "installation", unsuspend), + ); err != nil { + t.Fatalf("unsuspend Handle() error = %v", err) + } + if connection.Status != ConnectionStatusActive || connection.CredentialGeneration != 4 { + t.Fatalf("unsuspended connection = %+v", connection) + } + + deleted := []byte(`{"action":"deleted","installation":{"id":42}}`) + if _, err := service.Handle( + context.Background(), signedWebhookRequest("webhook-secret", "delivery-delete", "installation", deleted), + ); err != nil { + t.Fatalf("delete Handle() error = %v", err) + } + if connection.Status != ConnectionStatusRevoked || connection.CredentialGeneration != 5 { + t.Fatalf("deleted connection = %+v", connection) + } +} + +func TestGitHubWebhookLateUnsuspendCannotReactivateDeletedInstallation(t *testing.T) { + installationID := int64(42) + connection := &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, Status: ConnectionStatusRevoked, CredentialGeneration: 5, + } + store := &githubWebhookMemoryStore{workspaces: map[int64][]*WorkspaceConnection{42: {connection}}} + service := NewGitHubWebhookService("webhook-secret", store, nil, nil) + payload := []byte(`{"action":"unsuspend","installation":{"id":42}}`) + result, err := service.Handle( + context.Background(), signedWebhookRequest("webhook-secret", "delivery-late", "installation", payload), + ) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + if result.Affected != 0 || connection.Status != ConnectionStatusRevoked || connection.CredentialGeneration != 5 { + t.Fatalf("Handle() = %+v, connection = %+v", result, connection) + } +} + +func TestGitHubWebhookReconcilesOutOfOrderInstallationEventsWithProvider(t *testing.T) { + installationID := int64(42) + connection := &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, InstallationAccountLogin: "acme", + InstallationAccountType: "Organization", Status: ConnectionStatusSuspended, + CredentialGeneration: 5, + } + store := &githubWebhookMemoryStore{workspaces: map[int64][]*WorkspaceConnection{42: {connection}}} + verifier := &webhookInstallationVerifier{installation: AppInstallation{ + ID: 42, AccountLogin: "acme", AccountType: "Organization", + }} + service := NewGitHubWebhookService( + "webhook-secret", store, nil, nil, + GitHubWebhookReconciliation{Installations: verifier}, + ) + + result, err := service.Handle(context.Background(), signedWebhookRequest( + "webhook-secret", "delivery-delayed-suspend", "installation", + []byte(`{"action":"suspend","installation":{"id":42}}`), + )) + if err != nil || result.Affected != 1 || connection.Status != ConnectionStatusActive { + t.Fatalf("delayed suspend result=%+v err=%v connection=%+v", result, err, connection) + } + suspendedAt := time.Now().UTC() + verifier.installation.SuspendedAt = &suspendedAt + result, err = service.Handle(context.Background(), signedWebhookRequest( + "webhook-secret", "delivery-delayed-unsuspend", "installation", + []byte(`{"action":"unsuspend","installation":{"id":42}}`), + )) + if err != nil || result.Affected != 1 || connection.Status != ConnectionStatusSuspended { + t.Fatalf("delayed unsuspend result=%+v err=%v connection=%+v", result, err, connection) + } +} + +func TestGitHubWebhookUnknownInstallationDoesNotCreateBinding(t *testing.T) { + store := &githubWebhookMemoryStore{} + service := NewGitHubWebhookService("webhook-secret", store, nil, nil) + payload := []byte(`{"action":"created","installation":{"id":999}}`) + result, err := service.Handle( + context.Background(), signedWebhookRequest("webhook-secret", "delivery-2", "installation", payload), + ) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + if result.Affected != 0 || result.Status != WebhookDeliveryStatusIgnored || store.updates != 0 { + t.Fatalf("Handle() = %+v, updates = %d", result, store.updates) + } +} + +func TestGitHubWebhookStaleInstallationRowCannotReplacePAT(t *testing.T) { + installationID := int64(42) + connection := &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, Status: ConnectionStatusActive, CredentialGeneration: 2, + } + store := &githubWebhookMemoryStore{workspaces: map[int64][]*WorkspaceConnection{42: {connection}}} + store.beforeTransition = func() { + connection.Source = ConnectionSourcePAT + connection.InstallationID = nil + connection.Login = "octocat" + connection.CredentialGeneration = 3 + } + service := NewGitHubWebhookService("webhook-secret", store, nil, nil) + payload := []byte(`{"action":"suspend","installation":{"id":42}}`) + + result, err := service.Handle( + context.Background(), signedWebhookRequest("webhook-secret", "delivery-stale-binding", "installation", payload), + ) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + if result.Affected != 0 || store.updates != 0 || connection.Source != ConnectionSourcePAT || + connection.InstallationID != nil || connection.Status != ConnectionStatusActive || + connection.CredentialGeneration != 3 { + t.Fatalf("Handle() = %+v, connection = %+v, updates = %d", result, connection, store.updates) + } +} + +func TestGitHubWebhookAppliesRepositoryChangesOnlyToKnownBindings(t *testing.T) { + installationID := int64(42) + store := &githubWebhookMemoryStore{workspaces: map[int64][]*WorkspaceConnection{42: {{ + WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation, + AppRegistrationID: "registration-work", InstallationID: &installationID, + Status: ConnectionStatusActive, CredentialGeneration: 3, + }}}} + repos := &webhookRepoUpdater{} + service := newRegistrationWebhookService("registration-work", "webhook-secret", store, repos, nil) + payload := []byte(`{ + "action":"added","installation":{"id":42}, + "repositories_added":[{"id":7,"full_name":"acme/repo"}],"repositories_removed":[] + }`) + result, err := service.Handle( + context.Background(), signedWebhookRequest("webhook-secret", "delivery-3", "installation_repositories", payload), + ) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + if result.Affected != 1 || len(repos.updates) != 1 || repos.updates[0].WorkspaceID != "workspace-1" || + repos.updates[0].AppRegistrationID != "registration-work" || + repos.updates[0].CredentialGeneration != 3 || len(repos.updates[0].Added) != 1 || + repos.updates[0].Added[0].FullName != "acme/repo" { + t.Fatalf("Handle() = %+v, repository updates = %+v", result, repos.updates) + } +} + +func TestInstallationRepositoryUpdateIgnoresReplacedAppBinding(t *testing.T) { + service, _ := newWorkspaceConnectionService(t, "octocat") + installationID := int64(42) + appConnection := activeAppWorkspace("ws-1", installationID) + appConnection.CredentialGeneration = 2 + if err := service.store.UpsertWorkspaceConnection(context.Background(), appConnection); err != nil { + t.Fatal(err) + } + if err := service.store.UpsertWorkspaceSettings(context.Background(), &WorkspaceSettings{ + WorkspaceID: "ws-1", RepoScopeMode: RepoScopeModeRepos, + RepoScopeRepos: []RepoFilter{{Owner: "acme", Name: "repo"}}, + }); err != nil { + t.Fatal(err) + } + if err := service.store.UpsertWorkspaceConnection(context.Background(), &WorkspaceConnection{ + WorkspaceID: "ws-1", Source: ConnectionSourcePAT, Login: "octocat", + GitHubHost: "github.com", Status: ConnectionStatusActive, CredentialGeneration: 3, + }); err != nil { + t.Fatal(err) + } + + updated, err := (&installationRepositorySettingsUpdater{service: service}).ApplyInstallationRepositories( + context.Background(), + InstallationRepositoriesChange{ + WorkspaceID: "ws-1", InstallationID: installationID, + ConnectionSource: ConnectionSourceGitHubAppInstallation, CredentialGeneration: 2, + Action: "removed", Removed: []InstallationRepository{{ID: 7, FullName: "acme/repo"}}, + }, + ) + if err != nil || updated { + t.Fatalf("ApplyInstallationRepositories() updated=%v, err=%v", updated, err) + } + settings, err := service.store.GetWorkspaceSettings(context.Background(), "ws-1") + if err != nil || len(settings.RepoScopeRepos) != 1 || settings.RepoScopeRepos[0].Name != "repo" { + t.Fatalf("workspace settings after stale webhook = %+v, err=%v", settings, err) + } +} + +func TestGitHubWebhookAuthorizationRevocationRemovesExactUsers(t *testing.T) { + store := &githubWebhookMemoryStore{users: map[int64][]*UserConnection{11: { + {WorkspaceID: "workspace-1", UserID: "user-1", GitHubUserID: 11}, + {WorkspaceID: "workspace-2", UserID: "user-9", GitHubUserID: 11}, + }}} + revoker := &webhookPersonalRevoker{} + service := NewGitHubWebhookService("webhook-secret", store, nil, revoker) + payload := []byte(`{"action":"revoked","sender":{"id":11,"login":"octocat"}}`) + result, err := service.Handle( + context.Background(), signedWebhookRequest("webhook-secret", "delivery-4", "github_app_authorization", payload), + ) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + if result.Affected != 2 || len(revoker.revoked) != 2 || revoker.revoked[0] != "workspace-1:user-1" { + t.Fatalf("Handle() = %+v, revoked = %v", result, revoker.revoked) + } +} + +func TestGitHubWebhookLateAuthorizationRevokePreservesVerifiedReconnect(t *testing.T) { + store := &githubWebhookMemoryStore{users: map[int64][]*UserConnection{11: {{ + WorkspaceID: "workspace-1", UserID: "user-1", GitHubUserID: 11, + CredentialGeneration: 2, + }}}} + reconciler := &webhookPersonalReconciler{} + service := NewGitHubWebhookService( + "webhook-secret", store, nil, nil, + GitHubWebhookReconciliation{Personal: reconciler}, + ) + payload := []byte(`{"action":"revoked","sender":{"id":11}}`) + result, err := service.Handle(context.Background(), signedWebhookRequest( + "webhook-secret", "delivery-late-revoke", "github_app_authorization", payload, + )) + if err != nil || result.Affected != 0 || reconciler.calls != 1 { + t.Fatalf("Handle()=%+v err=%v reconciler calls=%d", result, err, reconciler.calls) + } +} + +func TestGitHubWebhookRetriesFailedDelivery(t *testing.T) { + installationID := int64(42) + connection := &WorkspaceConnection{ + WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation, + InstallationID: &installationID, Status: ConnectionStatusActive, CredentialGeneration: 2, + } + store := &githubWebhookMemoryStore{ + workspaces: map[int64][]*WorkspaceConnection{42: {connection}}, + } + repos := &webhookRepoUpdater{err: errors.New("temporary database failure")} + service := NewGitHubWebhookService("webhook-secret", store, repos, nil) + payload := []byte(`{ + "action":"added","installation":{"id":42}, + "repositories_added":[{"id":7,"full_name":"acme/repo"}],"repositories_removed":[] + }`) + request := signedWebhookRequest("webhook-secret", "delivery-retry", "installation_repositories", payload) + + if _, err := service.Handle(context.Background(), request); err == nil { + t.Fatal("first Handle() unexpectedly succeeded") + } + if got := store.deliveries[":"+request.DeliveryID].Status; got != WebhookDeliveryStatusFailed { + t.Fatalf("failed delivery status = %q", got) + } + result, err := service.Handle(context.Background(), request) + if err != nil { + t.Fatalf("retry Handle() error = %v", err) + } + if result.Duplicate || result.Affected != 1 || len(repos.updates) != 1 || + store.deliveries[":"+request.DeliveryID].Status != WebhookDeliveryStatusProcessed { + t.Fatalf( + "retry result=%+v updates=%d delivery=%+v", + result, len(repos.updates), store.deliveries[":"+request.DeliveryID], + ) + } +} + +func TestGitHubWebhookReclaimsStaleReceivedDelivery(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + store := &githubWebhookMemoryStore{deliveries: map[string]*WebhookDelivery{ + ":delivery-stale": { + DeliveryID: "delivery-stale", Event: "installation", Status: WebhookDeliveryStatusReceived, + ReceivedAt: now.Add(-webhookDeliveryStaleAfter - time.Second), + }, + }} + service := NewGitHubWebhookService("webhook-secret", store, nil, nil) + service.now = func() time.Time { return now } + payload := []byte(`{"action":"created","installation":{"id":999}}`) + result, err := service.Handle( + context.Background(), signedWebhookRequest("webhook-secret", "delivery-stale", "installation", payload), + ) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + if result.Duplicate || result.Status != WebhookDeliveryStatusIgnored { + t.Fatalf("Handle() = %+v", result) + } +} + +func signedWebhookRequest(secret, deliveryID, event string, payload []byte) GitHubWebhookRequest { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write(payload) + return GitHubWebhookRequest{ + DeliveryID: deliveryID, + Event: event, + Signature: "sha256=" + hex.EncodeToString(mac.Sum(nil)), + Payload: payload, + } +} diff --git a/apps/backend/internal/github/workspace_settings_service.go b/apps/backend/internal/github/workspace_settings_service.go index 0313d1d21f..61b2423a1b 100644 --- a/apps/backend/internal/github/workspace_settings_service.go +++ b/apps/backend/internal/github/workspace_settings_service.go @@ -9,8 +9,16 @@ import ( var ErrWorkspaceSettingsValidation = errors.New("invalid workspace settings") +const ( + personalSearchFetchPageSize = 100 + githubSearchResultLimit = 1000 +) + // GetWorkspaceSettings returns the GitHub operational settings for a workspace. func (s *Service) GetWorkspaceSettings(ctx context.Context, workspaceID string) (*WorkspaceSettings, error) { + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } if s.store == nil { return defaultWorkspaceSettings(workspaceID), nil } @@ -19,6 +27,12 @@ func (s *Service) GetWorkspaceSettings(ctx context.Context, workspaceID string) // UpsertWorkspaceSettings stores the GitHub operational settings for a workspace. func (s *Service) UpsertWorkspaceSettings(ctx context.Context, settings *WorkspaceSettings) error { + if settings == nil { + return fmt.Errorf("workspace settings are required") + } + if err := s.authorizeWorkspaceAccess(ctx, settings.WorkspaceID); err != nil { + return err + } if s.store == nil { return fmt.Errorf("github store not configured") } @@ -34,6 +48,9 @@ func (s *Service) UpdateWorkspaceSettings(ctx context.Context, req *UpdateWorksp if req == nil || strings.TrimSpace(req.WorkspaceID) == "" { return nil, fmt.Errorf("%w: workspace_id is required", ErrWorkspaceSettingsValidation) } + if err := s.authorizeWorkspaceAccess(ctx, req.WorkspaceID); err != nil { + return nil, err + } if req.RepoScopeMode != nil && !isValidRepoScopeMode(*req.RepoScopeMode) { return nil, fmt.Errorf("%w: invalid repo_scope_mode %q", ErrWorkspaceSettingsValidation, *req.RepoScopeMode) } @@ -51,14 +68,33 @@ func (s *Service) SearchUserPRsPagedForWorkspace( page int, perPage int, ) (*PRSearchPage, error) { - settings, err := s.GetWorkspaceSettings(ctx, workspaceID) - if err != nil { - return nil, err - } - if !workspaceSettingsHasScope(settings) { - return s.SearchUserPRsPaged(ctx, filter, customQuery, page, perPage) - } - return s.searchUserPRsPagedScoped(ctx, settings, filter, customQuery, page, perPage) + return s.SearchUserPRsPagedForWorkspaceUser(ctx, workspaceID, "", filter, customQuery, page, perPage) +} + +//nolint:dupl // The PR and issue adapters preserve their strongly typed public response contracts. +func (s *Service) SearchUserPRsPagedForWorkspaceUser( + ctx context.Context, + workspaceID string, + userID string, + filter string, + customQuery string, + page int, + perPage int, +) (*PRSearchPage, error) { + return searchPersonalWorkspacePageResult( + ctx, s, workspaceID, userID, page, perPage, + func(resolved *resolvedServiceClient, settings *WorkspaceSettings, providerPage, providerPerPage int) ([]*PR, int, error) { + result, fetchErr := s.searchPersonalPRPage(ctx, resolved, settings, filter, customQuery, providerPage, providerPerPage) + if fetchErr != nil || result == nil { + return nil, 0, fetchErr + } + return result.PRs, result.TotalCount, nil + }, + func(items []*PR) ([]*PR, error) { return s.filterPersonalPRsByAutomation(ctx, workspaceID, items) }, + func(result filteredPersonalSearchPage[*PR], page, perPage int) *PRSearchPage { + return &PRSearchPage{PRs: result.items, TotalCount: result.total, Page: page, PerPage: perPage} + }, + ) } func (s *Service) SearchUserIssuesPagedForWorkspace( @@ -69,18 +105,216 @@ func (s *Service) SearchUserIssuesPagedForWorkspace( page int, perPage int, ) (*IssueSearchPage, error) { + return s.SearchUserIssuesPagedForWorkspaceUser(ctx, workspaceID, "", filter, customQuery, page, perPage) +} + +//nolint:dupl // The PR and issue adapters preserve their strongly typed public response contracts. +func (s *Service) SearchUserIssuesPagedForWorkspaceUser( + ctx context.Context, + workspaceID string, + userID string, + filter string, + customQuery string, + page int, + perPage int, +) (*IssueSearchPage, error) { + return searchPersonalWorkspacePageResult( + ctx, s, workspaceID, userID, page, perPage, + func(resolved *resolvedServiceClient, settings *WorkspaceSettings, providerPage, providerPerPage int) ([]*Issue, int, error) { + result, fetchErr := s.searchPersonalIssuePage(ctx, resolved, settings, filter, customQuery, providerPage, providerPerPage) + if fetchErr != nil || result == nil { + return nil, 0, fetchErr + } + return result.Issues, result.TotalCount, nil + }, + func(items []*Issue) ([]*Issue, error) { + return s.filterPersonalIssuesByAutomation(ctx, workspaceID, items) + }, + func(result filteredPersonalSearchPage[*Issue], page, perPage int) *IssueSearchPage { + return &IssueSearchPage{ + Issues: result.items, TotalCount: result.total, Page: page, PerPage: perPage, + } + }, + ) +} + +func (s *Service) resolvePersonalSearchContext( + ctx context.Context, workspaceID, userID string, +) (*resolvedServiceClient, *WorkspaceSettings, error) { + resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, "", "") + if err != nil { + return nil, nil, err + } settings, err := s.GetWorkspaceSettings(ctx, workspaceID) + if err != nil { + return nil, nil, err + } + return resolved, settings, nil +} + +func (s *Service) searchPersonalPRPage( + ctx context.Context, + resolved *resolvedServiceClient, + settings *WorkspaceSettings, + filter, customQuery string, + page, perPage int, +) (*PRSearchPage, error) { + if !workspaceSettingsHasScope(settings) { + return s.searchUserPRsPagedWithClient( + ctx, resolved.Client, resolved.CacheScope, filter, customQuery, page, perPage, + ) + } + return s.searchUserPRsPagedScoped( + ctx, resolved.Client, resolved.CacheScope, settings, filter, customQuery, page, perPage, + ) +} + +func (s *Service) searchPersonalIssuePage( + ctx context.Context, + resolved *resolvedServiceClient, + settings *WorkspaceSettings, + filter, customQuery string, + page, perPage int, +) (*IssueSearchPage, error) { + if !workspaceSettingsHasScope(settings) { + return s.searchUserIssuesPagedWithClient( + ctx, resolved.Client, resolved.CacheScope, filter, customQuery, page, perPage, + ) + } + return s.searchUserIssuesPagedScoped( + ctx, resolved.Client, resolved.CacheScope, settings, filter, customQuery, page, perPage, + ) +} + +type filteredPersonalSearchPage[T any] struct { + items []T + total int +} + +func searchPersonalWorkspacePageResult[T any, R any]( + ctx context.Context, + service *Service, + workspaceID, userID string, + page, perPage int, + fetch func(*resolvedServiceClient, *WorkspaceSettings, int, int) ([]T, int, error), + filter func([]T) ([]T, error), + build func(filteredPersonalSearchPage[T], int, int) R, +) (R, error) { + result, page, perPage, err := searchPersonalWorkspacePage( + ctx, service, workspaceID, userID, page, perPage, fetch, filter, + ) + if err != nil { + var zero R + return zero, err + } + return build(result, page, perPage), nil +} + +func searchPersonalWorkspacePage[T any]( + ctx context.Context, + service *Service, + workspaceID, userID string, + page, perPage int, + fetch func(*resolvedServiceClient, *WorkspaceSettings, int, int) ([]T, int, error), + filter func([]T) ([]T, error), +) (filteredPersonalSearchPage[T], int, int, error) { + resolved, settings, err := service.resolvePersonalSearchContext(ctx, workspaceID, userID) + if err != nil { + return filteredPersonalSearchPage[T]{}, page, perPage, err + } + page, perPage = clampSearchPage(page, perPage) + if resolved.Principal.UserID == "" { + items, total, fetchErr := fetch(resolved, settings, page, perPage) + return filteredPersonalSearchPage[T]{items: items, total: total}, page, perPage, fetchErr + } + result, err := searchFilteredPersonalPage(page, perPage, func(providerPage int) ([]T, int, error) { + return fetch(resolved, settings, providerPage, personalSearchFetchPageSize) + }, filter) + return result, page, perPage, err +} + +// searchFilteredPersonalPage scans GitHub's bounded Search API result window +// before applying the workspace automation boundary. Filtering one requested +// provider page at a time can otherwise produce empty pages while permitted +// results remain later in the search. +func searchFilteredPersonalPage[T any]( + page, perPage int, + fetch func(providerPage int) ([]T, int, error), + filter func([]T) ([]T, error), +) (filteredPersonalSearchPage[T], error) { + all := make([]T, 0, personalSearchFetchPageSize) + resultLimit := githubSearchResultLimit + for providerPage := 1; (providerPage-1)*personalSearchFetchPageSize < resultLimit; providerPage++ { + items, total, err := fetch(providerPage) + if err != nil { + return filteredPersonalSearchPage[T]{}, err + } + if providerPage == 1 && total < resultLimit { + resultLimit = max(total, 0) + } + all = append(all, items...) + if providerPage*personalSearchFetchPageSize >= resultLimit { + break + } + } + filtered, err := filter(all) + if err != nil { + return filteredPersonalSearchPage[T]{}, err + } + return filteredPersonalSearchPage[T]{ + items: paginateSearchResults(filtered, page, perPage), + total: len(filtered), + }, nil +} + +func (s *Service) filterPersonalPRsByAutomation( + ctx context.Context, workspaceID string, prs []*PR, +) ([]*PR, error) { + repositories := make([]RepoFilter, 0, len(prs)) + for _, pr := range prs { + if pr != nil { + repositories = append(repositories, RepoFilter{Owner: pr.RepoOwner, Name: pr.RepoName}) + } + } + visible, err := s.automationRepositoryVisibility(ctx, workspaceID, repositories) if err != nil { return nil, err } - if !workspaceSettingsHasScope(settings) { - return s.SearchUserIssuesPaged(ctx, filter, customQuery, page, perPage) + filtered := make([]*PR, 0, len(prs)) + for _, pr := range prs { + if pr != nil && visible[repositoryIdentityKey(pr.RepoOwner, pr.RepoName)] { + filtered = append(filtered, pr) + } } - return s.searchUserIssuesPagedScoped(ctx, settings, filter, customQuery, page, perPage) + return filtered, nil +} + +func (s *Service) filterPersonalIssuesByAutomation( + ctx context.Context, workspaceID string, issues []*Issue, +) ([]*Issue, error) { + repositories := make([]RepoFilter, 0, len(issues)) + for _, issue := range issues { + if issue != nil { + repositories = append(repositories, RepoFilter{Owner: issue.RepoOwner, Name: issue.RepoName}) + } + } + visible, err := s.automationRepositoryVisibility(ctx, workspaceID, repositories) + if err != nil { + return nil, err + } + filtered := make([]*Issue, 0, len(issues)) + for _, issue := range issues { + if issue != nil && visible[repositoryIdentityKey(issue.RepoOwner, issue.RepoName)] { + filtered = append(filtered, issue) + } + } + return filtered, nil } func (s *Service) searchUserPRsPagedScoped( ctx context.Context, + client Client, + cacheScope string, settings *WorkspaceSettings, filter string, customQuery string, @@ -92,11 +326,11 @@ func (s *Service) searchUserPRsPagedScoped( } qualifiers := workspaceScopeQualifiers(settings) if len(qualifiers) > 1 { - return s.searchUserPRsPagedScopedAcrossQualifiers(ctx, settings, filter, customQuery, page, perPage, qualifiers) + return s.searchUserPRsPagedScopedAcrossQualifiers(ctx, client, cacheScope, settings, filter, customQuery, page, perPage, qualifiers) } filter, customQuery = appendWorkspaceScopeToSearch(filter, customQuery, settings) - v, err := s.searchUserPagedScoped("pr", settings, filter, customQuery, page, perPage, func(page, perPage int) (any, error) { - result, err := s.client.SearchPRsPaged(ctx, filter, customQuery, page, perPage) + v, err := s.searchUserPagedScoped(cacheScope, "pr", settings, filter, customQuery, page, perPage, func(page, perPage int) (any, error) { + result, err := client.SearchPRsPaged(ctx, filter, customQuery, page, perPage) if err != nil { return nil, err } @@ -110,6 +344,8 @@ func (s *Service) searchUserPRsPagedScoped( func (s *Service) searchUserIssuesPagedScoped( ctx context.Context, + client Client, + cacheScope string, settings *WorkspaceSettings, filter string, customQuery string, @@ -121,11 +357,11 @@ func (s *Service) searchUserIssuesPagedScoped( } qualifiers := workspaceScopeQualifiers(settings) if len(qualifiers) > 1 { - return s.searchUserIssuesPagedScopedAcrossQualifiers(ctx, settings, filter, customQuery, page, perPage, qualifiers) + return s.searchUserIssuesPagedScopedAcrossQualifiers(ctx, client, cacheScope, settings, filter, customQuery, page, perPage, qualifiers) } filter, customQuery = appendWorkspaceScopeToSearch(filter, customQuery, settings) - v, err := s.searchUserPagedScoped("issue", settings, filter, customQuery, page, perPage, func(page, perPage int) (any, error) { - result, err := s.client.ListIssuesPaged(ctx, filter, customQuery, page, perPage) + v, err := s.searchUserPagedScoped(cacheScope, "issue", settings, filter, customQuery, page, perPage, func(page, perPage int) (any, error) { + result, err := client.ListIssuesPaged(ctx, filter, customQuery, page, perPage) if err != nil { return nil, err } @@ -138,6 +374,7 @@ func (s *Service) searchUserIssuesPagedScoped( } func (s *Service) searchUserPagedScoped( + cacheScope string, kind string, settings *WorkspaceSettings, filter string, @@ -146,12 +383,9 @@ func (s *Service) searchUserPagedScoped( perPage int, fetch func(page int, perPage int) (any, error), ) (any, error) { - if s.client == nil { - return nil, fmt.Errorf("github client not available") - } page, perPage = clampSearchPage(page, perPage) scopeKey := workspaceSearchScopeKey(settings) - key := searchCacheKey(kind+":"+scopeKey, filter, customQuery, page, perPage) + key := scopedCacheKey(cacheScope, searchCacheKey(kind+":"+scopeKey, filter, customQuery, page, perPage)) return s.searchCache.doOrFetch(key, func() (any, error) { return fetch(page, perPage) }) @@ -159,6 +393,8 @@ func (s *Service) searchUserPagedScoped( func (s *Service) searchUserPRsPagedScopedAcrossQualifiers( ctx context.Context, + client Client, + cacheScope string, settings *WorkspaceSettings, filter string, customQuery string, @@ -166,8 +402,8 @@ func (s *Service) searchUserPRsPagedScopedAcrossQualifiers( perPage int, qualifiers []string, ) (*PRSearchPage, error) { - result, err := cachedScopedSearchAcrossQualifiers(s.client, s.searchCache, "pr", settings, filter, customQuery, page, perPage, func(page, perPage int) ([]*PR, int, error) { - return s.fetchScopedPRsAcrossQualifiers(ctx, settings, filter, customQuery, page, perPage, qualifiers) + result, err := cachedScopedSearchAcrossQualifiers(client, s.searchCache, cacheScope+":pr", settings, filter, customQuery, page, perPage, func(page, perPage int) ([]*PR, int, error) { + return s.fetchScopedPRsAcrossQualifiers(ctx, client, settings, filter, customQuery, page, perPage, qualifiers) }) if err != nil { return nil, err @@ -177,6 +413,8 @@ func (s *Service) searchUserPRsPagedScopedAcrossQualifiers( func (s *Service) searchUserIssuesPagedScopedAcrossQualifiers( ctx context.Context, + client Client, + cacheScope string, settings *WorkspaceSettings, filter string, customQuery string, @@ -184,8 +422,8 @@ func (s *Service) searchUserIssuesPagedScopedAcrossQualifiers( perPage int, qualifiers []string, ) (*IssueSearchPage, error) { - result, err := cachedScopedSearchAcrossQualifiers(s.client, s.searchCache, "issue", settings, filter, customQuery, page, perPage, func(page, perPage int) ([]*Issue, int, error) { - return s.fetchScopedIssuesAcrossQualifiers(ctx, settings, filter, customQuery, page, perPage, qualifiers) + result, err := cachedScopedSearchAcrossQualifiers(client, s.searchCache, cacheScope+":issue", settings, filter, customQuery, page, perPage, func(page, perPage int) ([]*Issue, int, error) { + return s.fetchScopedIssuesAcrossQualifiers(ctx, client, settings, filter, customQuery, page, perPage, qualifiers) }) if err != nil { return nil, err @@ -237,6 +475,7 @@ func cachedScopedSearchAcrossQualifiers[T any]( func (s *Service) fetchScopedPRsAcrossQualifiers( ctx context.Context, + client Client, settings *WorkspaceSettings, filter string, customQuery string, @@ -245,7 +484,7 @@ func (s *Service) fetchScopedPRsAcrossQualifiers( qualifiers []string, ) ([]*PR, int, error) { return fetchScopedResultsAcrossQualifiers(settings, filter, customQuery, page, perPage, qualifiers, prScopeKey, func(scopedFilter, scopedCustomQuery string, providerPage, fetchPerPage int) ([]*PR, int, error) { - result, err := s.client.SearchPRsPaged(ctx, scopedFilter, scopedCustomQuery, providerPage, fetchPerPage) + result, err := client.SearchPRsPaged(ctx, scopedFilter, scopedCustomQuery, providerPage, fetchPerPage) if err != nil { return nil, 0, err } @@ -258,6 +497,7 @@ func (s *Service) fetchScopedPRsAcrossQualifiers( func (s *Service) fetchScopedIssuesAcrossQualifiers( ctx context.Context, + client Client, settings *WorkspaceSettings, filter string, customQuery string, @@ -266,7 +506,7 @@ func (s *Service) fetchScopedIssuesAcrossQualifiers( qualifiers []string, ) ([]*Issue, int, error) { return fetchScopedResultsAcrossQualifiers(settings, filter, customQuery, page, perPage, qualifiers, issueScopeKey, func(scopedFilter, scopedCustomQuery string, providerPage, fetchPerPage int) ([]*Issue, int, error) { - result, err := s.client.ListIssuesPaged(ctx, scopedFilter, scopedCustomQuery, providerPage, fetchPerPage) + result, err := client.ListIssuesPaged(ctx, scopedFilter, scopedCustomQuery, providerPage, fetchPerPage) if err != nil { return nil, 0, err } diff --git a/apps/backend/internal/github/workspace_settings_test.go b/apps/backend/internal/github/workspace_settings_test.go index 00a058c2aa..cedcd5aec5 100644 --- a/apps/backend/internal/github/workspace_settings_test.go +++ b/apps/backend/internal/github/workspace_settings_test.go @@ -52,7 +52,7 @@ func TestService_SearchUserPRsPagedForWorkspace_FiltersToSelectedRepos(t *testin client.AddPR(&PR{RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "in scope"}) client.AddPR(&PR{RepoOwner: "other", RepoName: "repo", Number: 2, Title: "out of scope"}) store := newTestStore(t) - svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, client, store, "ws-1") ctx := context.Background() if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ @@ -79,7 +79,7 @@ func TestService_SearchUserPRsPagedForWorkspace_AppendsScopeToQuery(t *testing.T client := &capturingSearchClient{MockClient: NewMockClient()} client.AddPR(&PR{RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "in scope"}) store := newTestStore(t) - svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, client, store, "ws-1") ctx := context.Background() if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ @@ -140,7 +140,7 @@ func TestService_SearchUserPRsPagedForWorkspace_OrgScopeIncludesPersonalOwner(t client := &capturingSearchClient{MockClient: NewMockClient()} client.AddPR(&PR{RepoOwner: "octo", RepoName: "personal", Number: 1, Title: "in scope"}) store := newTestStore(t) - svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, client, store, "ws-1") ctx := context.Background() if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ @@ -166,7 +166,7 @@ func TestService_SearchUserPRsPagedForWorkspace_FanoutFetchesDeeperProviderPages client.AddPR(&PR{RepoOwner: "example", RepoName: "api", Number: i, Title: fmt.Sprintf("api-%d", i)}) } store := newTestStore(t) - svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, client, store, "ws-1") ctx := context.Background() if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ @@ -205,7 +205,7 @@ func TestService_SearchUserPRsPagedForWorkspace_OrgScopeDedupesFanoutTotal(t *te client.AddPR(&PR{RepoOwner: "octo", RepoName: "repo", Number: i, Title: fmt.Sprintf("octo-%d", i)}) } store := newTestStore(t) - svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, client, store, "ws-1") ctx := context.Background() if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ @@ -233,7 +233,7 @@ func TestService_SearchUserPRsPagedForWorkspace_EmptyRepoScopeFailsClosed(t *tes client := NewMockClient() client.AddPR(&PR{RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "hidden"}) store := newTestStore(t) - svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, client, store, "ws-1") ctx := context.Background() if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ @@ -255,7 +255,7 @@ func TestService_SearchUserPRsPagedForWorkspace_EmptyRepoScopeFailsClosed(t *tes func TestService_UpdateWorkspaceSettings_PartialUpdateAndNullClear(t *testing.T) { store := newTestStore(t) - svc := NewService(NewMockClient(), AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, NewMockClient(), store, "ws-1") ctx := context.Background() defaults := json.RawMessage(`{"pr":[{"value":"mine","label":"Mine","filter":"is:open","group":"inbox"}],"issue":[]}`) saved := json.RawMessage(`[{"id":"p1","kind":"pr","label":"Mine"}]`) @@ -343,7 +343,7 @@ func TestUpdateWorkspaceSettingsRequest_UnmarshalTracksSavedPresetsNull(t *testi func TestService_UpdateWorkspaceSettings_RejectsUnknownRepoScopeMode(t *testing.T) { store := newTestStore(t) - svc := NewService(NewMockClient(), AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, NewMockClient(), store, "ws-1") mode := "everything" _, err := svc.UpdateWorkspaceSettings(context.Background(), &UpdateWorkspaceSettingsRequest{ @@ -357,7 +357,7 @@ func TestService_UpdateWorkspaceSettings_RejectsUnknownRepoScopeMode(t *testing. func TestService_UpdateWorkspaceSettings_RejectsConflictingScopePatch(t *testing.T) { store := newTestStore(t) - svc := NewService(NewMockClient(), AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, NewMockClient(), store, "ws-1") mode := RepoScopeModeAll orgs := []string{"octo"} @@ -379,7 +379,7 @@ func TestService_SearchUserIssuesPagedForWorkspace_AllScopePreservesResults(t *t }, } store := newTestStore(t) - svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, client, store, "ws-1") page, err := svc.SearchUserIssuesPagedForWorkspace(context.Background(), "ws-1", "", "is:open", 1, 25) if err != nil { @@ -403,7 +403,7 @@ func TestService_SearchUserIssuesPagedForWorkspace_FansOutSelectedRepos(t *testi }, } store := newTestStore(t) - svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, client, store, "ws-1") ctx := context.Background() if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ @@ -452,7 +452,7 @@ func TestService_CheckReviewWatch_AppliesWorkspaceRepoScope(t *testing.T) { RequestedReviewers: []RequestedReviewer{{Login: "octo", Type: "user"}}, }) store := newTestStore(t) - svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, client, store, "ws-1") ctx := context.Background() if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ @@ -489,7 +489,7 @@ func TestService_CheckReviewWatch_EmptyWorkspaceScopeSkipsProviderFetch(t *testi RequestedReviewers: []RequestedReviewer{{Login: "octo", Type: "user"}}, }) store := newTestStore(t) - svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, client, store, "ws-1") ctx := context.Background() if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ @@ -527,7 +527,7 @@ func TestService_CheckIssueWatch_AppliesWorkspaceRepoScope(t *testing.T) { }, } store := newTestStore(t) - svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, client, store, "ws-1") ctx := context.Background() if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ @@ -556,7 +556,7 @@ func TestService_CheckIssueWatch_AppliesWorkspaceRepoScope(t *testing.T) { func TestService_CheckIssueWatch_EmptyWorkspaceScopeSkipsProviderFetch(t *testing.T) { client := &countingWorkspaceIssueClient{MockClient: NewMockClient()} store := newTestStore(t) - svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + svc := newWorkspaceAuthenticatedTestService(t, client, store, "ws-1") ctx := context.Background() if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ diff --git a/apps/backend/internal/githubauth/environment.go b/apps/backend/internal/githubauth/environment.go new file mode 100644 index 0000000000..6512ddc33e --- /dev/null +++ b/apps/backend/internal/githubauth/environment.go @@ -0,0 +1,13 @@ +package githubauth + +const ( + CredentialBrokerURLEnv = "KANDEV_GITHUB_CREDENTIAL_BROKER_URL" + CredentialLeaseEnv = "KANDEV_GITHUB_CREDENTIAL_LEASE" + CredentialTaskIDEnv = "KANDEV_GITHUB_CREDENTIAL_TASK_ID" + CredentialSessionIDEnv = "KANDEV_GITHUB_CREDENTIAL_SESSION_ID" + CredentialRepositoryEnv = "KANDEV_GITHUB_CREDENTIAL_REPOSITORY_ID" + CredentialOwnerEnv = "KANDEV_GITHUB_CREDENTIAL_OWNER" + CredentialRepoEnv = "KANDEV_GITHUB_CREDENTIAL_REPO" + CredentialHostEnv = "KANDEV_GITHUB_CREDENTIAL_HOST" + CredentialScopesEnv = "KANDEV_GITHUB_CREDENTIAL_SCOPES" +) diff --git a/apps/backend/internal/health/checks.go b/apps/backend/internal/health/checks.go index fe2ee0f380..8f7933c259 100644 --- a/apps/backend/internal/health/checks.go +++ b/apps/backend/internal/health/checks.go @@ -10,8 +10,18 @@ import ( // GitHubStatusProvider abstracts the GitHub service status check. type GitHubStatusProvider interface { - IsAuthenticated() bool - AuthMethod() string + GitHubConnectionHealth(context.Context) (GitHubConnectionHealth, error) +} + +// GitHubConnectionHealth is an aggregate of persisted workspace-owned +// connections. It intentionally contains no process-global auth state. +type GitHubConnectionHealth struct { + WorkspaceCount int + Active int + Disconnected int + Invalid int + Suspended int + Revoked int } // GitHubRateLimitProvider exposes per-resource exhaustion state so the health @@ -53,30 +63,57 @@ func (c *GitHubChecker) Name() string { return "GitHub integration" } // Category returns the issue category this checker emits issues under. func (c *GitHubChecker) Category() string { return "github" } -func (c *GitHubChecker) Check(_ context.Context) []Issue { +func (c *GitHubChecker) Check(ctx context.Context) []Issue { if c.provider == nil { - return []Issue{{ + return append([]Issue{{ ID: "github_unavailable", Category: "github", Title: "GitHub integration unavailable", - Message: "Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.", + Message: "Configure a GitHub connection for a workspace.", Severity: SeverityWarning, FixURL: "/settings/integrations/github", FixLabel: "Configure GitHub", - }} + }}, c.rateLimitIssues()...) } - if !c.provider.IsAuthenticated() { - return []Issue{{ + health, err := c.provider.GitHubConnectionHealth(ctx) + if err != nil { + return append([]Issue{{ + ID: "github_status_unavailable", + Category: "github", + Title: "GitHub connection status unavailable", + Message: "Kandev could not read workspace GitHub connection status.", + Severity: SeverityWarning, + FixURL: "/settings/integrations/github", + FixLabel: "View status", + }}, c.rateLimitIssues()...) + } + issues := make([]Issue, 0, 1) + unhealthy := health.Disconnected + health.Invalid + health.Suspended + health.Revoked + if health.WorkspaceCount == 0 || (health.Active == 0 && unhealthy == health.WorkspaceCount) { + issues = append(issues, Issue{ ID: "github_not_authenticated", Category: "github", Title: "GitHub not authenticated", - Message: "Run 'gh auth login' or add a GITHUB_TOKEN secret.", + Message: "Configure a GitHub connection for a workspace.", Severity: SeverityWarning, FixURL: "/settings/integrations/github", FixLabel: "Configure GitHub", - }} + }) + } else if unhealthy > 0 { + issues = append(issues, Issue{ + ID: "github_workspace_connections_unhealthy", + Category: "github", + Title: "GitHub workspace connections need attention", + Message: fmt.Sprintf( + "%d of %d GitHub workspace connections need attention (%d disconnected, %d invalid, %d suspended, %d revoked).", + unhealthy, health.WorkspaceCount, health.Disconnected, health.Invalid, health.Suspended, health.Revoked, + ), + Severity: SeverityWarning, + FixURL: "/settings/integrations/github", + FixLabel: "Configure GitHub", + }) } - return c.rateLimitIssues() + return append(issues, c.rateLimitIssues()...) } // rateLimitIssues materializes one Issue per exhausted resource bucket. diff --git a/apps/backend/internal/health/checks_test.go b/apps/backend/internal/health/checks_test.go index 7b57a91a58..61a29a2c8b 100644 --- a/apps/backend/internal/health/checks_test.go +++ b/apps/backend/internal/health/checks_test.go @@ -2,6 +2,7 @@ package health import ( "context" + "errors" "fmt" "strings" "testing" @@ -11,12 +12,13 @@ import ( // --- GitHubChecker tests --- type mockGitHubProvider struct { - authenticated bool - authMethod string + health GitHubConnectionHealth + err error } -func (m *mockGitHubProvider) IsAuthenticated() bool { return m.authenticated } -func (m *mockGitHubProvider) AuthMethod() string { return m.authMethod } +func (m *mockGitHubProvider) GitHubConnectionHealth(context.Context) (GitHubConnectionHealth, error) { + return m.health, m.err +} // expectedGitHubFixURL is the live route in apps/web/app/settings/integrations/github. // Kept here so a typo or accidental rename breaks a single test instead of @@ -44,7 +46,9 @@ func TestGitHubChecker_NilProvider(t *testing.T) { } func TestGitHubChecker_NotAuthenticated(t *testing.T) { - checker := NewGitHubChecker(&mockGitHubProvider{authenticated: false, authMethod: "gh_cli"}) + checker := NewGitHubChecker(&mockGitHubProvider{health: GitHubConnectionHealth{ + WorkspaceCount: 2, Disconnected: 2, + }}) issues := checker.Check(context.Background()) if len(issues) != 1 { t.Fatalf("expected 1 issue, got %d", len(issues)) @@ -61,7 +65,9 @@ func TestGitHubChecker_NotAuthenticated(t *testing.T) { } func TestGitHubChecker_Authenticated(t *testing.T) { - checker := NewGitHubChecker(&mockGitHubProvider{authenticated: true, authMethod: "gh_cli"}) + checker := NewGitHubChecker(&mockGitHubProvider{health: GitHubConnectionHealth{ + WorkspaceCount: 2, Active: 2, + }}) issues := checker.Check(context.Background()) if len(issues) != 0 { t.Errorf("expected 0 issues, got %d", len(issues)) @@ -80,7 +86,9 @@ func TestGitHubChecker_AuthenticatedButRateLimited(t *testing.T) { rate := &stubRateLimitProvider{exhausted: []GitHubRateLimitStatus{ {Resource: "graphql", ResetAt: time.Now().Add(15 * time.Minute)}, }} - checker := NewGitHubChecker(&mockGitHubProvider{authenticated: true, authMethod: "gh_cli"}) + checker := NewGitHubChecker(&mockGitHubProvider{health: GitHubConnectionHealth{ + WorkspaceCount: 1, Active: 1, + }}) checker.WithRateLimitProvider(rate) issues := checker.Check(context.Background()) if len(issues) != 1 { @@ -99,13 +107,41 @@ func TestGitHubChecker_AuthenticatedButRateLimited(t *testing.T) { func TestGitHubChecker_AuthenticatedRateProviderEmpty(t *testing.T) { rate := &stubRateLimitProvider{} - checker := NewGitHubChecker(&mockGitHubProvider{authenticated: true}) + checker := NewGitHubChecker(&mockGitHubProvider{health: GitHubConnectionHealth{ + WorkspaceCount: 1, Active: 1, + }}) checker.WithRateLimitProvider(rate) if got := checker.Check(context.Background()); len(got) != 0 { t.Errorf("expected 0 issues when rate provider empty, got %d", len(got)) } } +func TestGitHubChecker_ReportsDegradedWorkspacesAndRateLimitsTogether(t *testing.T) { + provider := &mockGitHubProvider{health: GitHubConnectionHealth{ + WorkspaceCount: 4, Active: 1, Disconnected: 1, Invalid: 1, Suspended: 1, + }} + rate := &stubRateLimitProvider{exhausted: []GitHubRateLimitStatus{{Resource: "core"}}} + issues := NewGitHubChecker(provider).WithRateLimitProvider(rate).Check(context.Background()) + if len(issues) != 2 { + t.Fatalf("issues = %+v, want connection and rate-limit issues", issues) + } + if issues[0].ID != "github_workspace_connections_unhealthy" || + !strings.Contains(issues[0].Message, "3 of 4") { + t.Fatalf("connection issue = %+v", issues[0]) + } + if issues[1].ID != "github_rate_limit_core" { + t.Fatalf("rate-limit issue = %+v", issues[1]) + } +} + +func TestGitHubChecker_StatusFailureDoesNotInspectAmbientAuth(t *testing.T) { + checker := NewGitHubChecker(&mockGitHubProvider{err: errors.New("database unavailable")}) + issues := checker.Check(context.Background()) + if len(issues) != 1 || issues[0].ID != "github_status_unavailable" { + t.Fatalf("issues = %+v", issues) + } +} + // --- GitExecutableChecker tests --- func TestGitExecutableChecker_Found(t *testing.T) { diff --git a/apps/backend/internal/health/service_test.go b/apps/backend/internal/health/service_test.go index 415b5b4c73..276ad34ce5 100644 --- a/apps/backend/internal/health/service_test.go +++ b/apps/backend/internal/health/service_test.go @@ -29,7 +29,7 @@ func TestRunChecks_NoCheckers(t *testing.T) { func TestRunChecks_AllHealthy(t *testing.T) { svc := NewService(newTestLogger(), - NewGitHubChecker(&mockGitHubProvider{authenticated: true}), + NewGitHubChecker(&mockGitHubProvider{health: GitHubConnectionHealth{WorkspaceCount: 1, Active: 1}}), NewAgentChecker(&mockAgentProvider{available: true}), ) result := svc.RunChecks(context.Background()) diff --git a/apps/backend/internal/improvekandev/handler.go b/apps/backend/internal/improvekandev/handler.go index 40e16403ec..5ac3ca3f7a 100644 --- a/apps/backend/internal/improvekandev/handler.go +++ b/apps/backend/internal/improvekandev/handler.go @@ -6,6 +6,7 @@ import ( "path/filepath" "github.com/gin-gonic/gin" + "github.com/google/uuid" "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" @@ -35,7 +36,10 @@ const ( // Cloner is the minimal subset of repoclone.Cloner the bootstrap endpoint uses. // Defined as an interface so tests can substitute a fake without network access. type Cloner interface { - EnsureCloned(ctx context.Context, cloneURL, owner, name string) (string, error) + EnsureWorkspaceCloned( + ctx context.Context, + workspaceID, provider, cloneURL, owner, name string, + ) (string, error) } // Handler exposes the improve-kandev HTTP endpoints. @@ -124,16 +128,21 @@ func (h *Handler) httpBootstrap(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"}) return } + workspaceID, err := canonicalWorkspaceID(req.WorkspaceID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id must be a UUID"}) + return + } ctx := c.Request.Context() - repo, err := h.resolveOrCloneRepo(ctx, req.WorkspaceID) + repo, err := h.resolveOrCloneRepo(ctx, workspaceID) if err != nil { h.log.Error("improve-kandev: repository upsert failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register kandev repository"}) return } - workflow, err := h.ensureWorkflow(ctx, req.WorkspaceID) + workflow, err := h.ensureWorkflow(ctx, workspaceID) if err != nil { h.log.Error("improve-kandev: workflow upsert failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to ensure improve-kandev workflow"}) @@ -176,6 +185,14 @@ func (h *Handler) httpBootstrap(c *gin.Context) { }) } +func canonicalWorkspaceID(value string) (string, error) { + id, err := uuid.Parse(value) + if err != nil { + return "", err + } + return id.String(), nil +} + // resolveOrCloneRepo returns the workspace's kandev repository, preferring an // existing entry — even one the user added themselves with no provider info — // over cloning a managed copy into ~/.kandev/repos. Order: @@ -200,7 +217,9 @@ func (h *Handler) resolveOrCloneRepo(ctx context.Context, workspaceID string) (* return match, nil } - localPath, err := h.cloner.EnsureCloned(ctx, repoCloneURL, repoOwner, repoName) + localPath, err := h.cloner.EnsureWorkspaceCloned( + ctx, workspaceID, repoProvider, repoCloneURL, repoOwner, repoName, + ) if err != nil { return nil, err } diff --git a/apps/backend/internal/improvekandev/handler_test.go b/apps/backend/internal/improvekandev/handler_test.go index a4b0469518..8fe4b18f03 100644 --- a/apps/backend/internal/improvekandev/handler_test.go +++ b/apps/backend/internal/improvekandev/handler_test.go @@ -39,6 +39,20 @@ func newTestHandler(gh GitHubInfo) *Handler { return &Handler{gh: gh, log: logger.Default()} } +func TestCanonicalWorkspaceID(t *testing.T) { + const canonical = "123e4567-e89b-12d3-a456-426614174000" + got, err := canonicalWorkspaceID("123E4567-E89B-12D3-A456-426614174000") + if err != nil { + t.Fatalf("canonicalWorkspaceID() error = %v", err) + } + if got != canonical { + t.Fatalf("canonicalWorkspaceID() = %q, want %q", got, canonical) + } + if _, err := canonicalWorkspaceID("../workspace"); err == nil { + t.Fatal("canonicalWorkspaceID() expected invalid UUID error") + } +} + func TestResolveGitHubAccess_Writable(t *testing.T) { gh := &fakeGitHubInfo{login: "alice", hasWrite: true} access := newTestHandler(gh).resolveGitHubAccess(context.Background()) diff --git a/apps/backend/internal/orchestrator/event_handlers_github.go b/apps/backend/internal/orchestrator/event_handlers_github.go index a58dba59cc..b3e63ae8d5 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github.go +++ b/apps/backend/internal/orchestrator/event_handlers_github.go @@ -34,9 +34,10 @@ const ( // row by colliding on the legacy UNIQUE(session_id) / UNIQUE(task_id, pr_number) // constraints. Empty repository_id preserves single-repo legacy behavior. type GitHubService interface { - Client() github.Client CreatePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string) (*github.PRWatch, error) - EnsurePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo, branch string) (*github.PRWatch, error) + CreatePRWatchForWorkspace(ctx context.Context, workspaceID, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string) (*github.PRWatch, error) + EnsurePRWatchForWorkspace(ctx context.Context, workspaceID, sessionID, taskID, repositoryID, owner, repo, branch string) (*github.PRWatch, error) + FindPRByBranchForWorkspace(ctx context.Context, workspaceID, owner, repo, branch string) (*github.PR, error) GetPRWatchBySession(ctx context.Context, sessionID string) (*github.PRWatch, error) GetPRWatchBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*github.PRWatch, error) GetPRWatchBySessionRepoAndBranch(ctx context.Context, sessionID, repositoryID, branch string) (*github.PRWatch, error) @@ -44,6 +45,7 @@ type GitHubService interface { UpdatePRWatchPRNumber(ctx context.Context, id string, prNumber int) error ResetPRWatch(ctx context.Context, id, branch string) error AssociatePRWithTask(ctx context.Context, taskID, repositoryID string, pr *github.PR) (*github.TaskPR, error) + AssociatePRWithTaskForWorkspace(ctx context.Context, workspaceID, taskID, repositoryID string, pr *github.PR) (*github.TaskPR, error) GetTaskPR(ctx context.Context, taskID string) (*github.TaskPR, error) ListTaskPRs(ctx context.Context, taskIDs []string) (map[string][]*github.TaskPR, error) TriggerPRSyncAll(ctx context.Context, taskID string) ([]*github.TaskPR, error) @@ -56,8 +58,8 @@ type GitHubService interface { RecordTaskCIError(ctx context.Context, taskID, repositoryID string, prNumber int, message string) error MarkTaskCIAutoFixExhausted(ctx context.Context, taskID, repositoryID string, prNumber int, message string) error ClearTaskCIError(ctx context.Context, taskID, repositoryID string, prNumber int) error - GetPRFeedback(ctx context.Context, owner, repo string, number int) (*github.PRFeedback, error) - MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) error + GetPRFeedbackForAutomation(ctx context.Context, workspaceID, owner, repo string, number int) (*github.PRFeedback, error) + MergePRForAutomation(ctx context.Context, workspaceID, owner, repo string, number int, mergeMethod string) error ListActivePRWatches(ctx context.Context) ([]*github.PRWatch, error) ReserveReviewPRTask(ctx context.Context, watchID, repoOwner, repoName string, prNumber int, prURL string) (bool, error) AssignReviewPRTaskID(ctx context.Context, watchID, repoOwner, repoName string, prNumber int, taskID string) error @@ -553,8 +555,8 @@ func (s *Service) detectPushAndAssociatePR( if s.githubService == nil { return } - client := s.githubService.Client() - if client == nil { + workspaceID := s.taskWorkspaceID(ctx, taskID) + if workspaceID == "" { return } @@ -575,7 +577,7 @@ func (s *Service) detectPushAndAssociatePR( if existing.PRNumber > 0 { return // PR already found and being monitored } - s.searchPRForExistingWatch(ctx, client, existing, sessionID, taskID, branch) + s.searchPRForExistingWatch(ctx, workspaceID, existing, sessionID, taskID, branch) return } @@ -593,7 +595,9 @@ func (s *Service) detectPushAndAssociatePR( return } } - foundPR, findErr := client.FindPRByBranch(ctx, owner, repoName, branch) + foundPR, findErr := s.githubService.FindPRByBranchForWorkspace( + ctx, workspaceID, owner, repoName, branch, + ) if findErr != nil || foundPR == nil { s.logger.Debug("no PR found for branch (will retry)", zap.String("branch", branch), @@ -608,7 +612,7 @@ func (s *Service) detectPushAndAssociatePR( zap.String("repository_name", repositoryName), zap.Int("pr_number", foundPR.Number), zap.String("branch", branch)) - s.associatePRFromPushScoped(ctx, sessionID, taskID, owner, repoName, repositoryID, branch, foundPR) + s.associatePRFromPushScoped(ctx, workspaceID, sessionID, taskID, owner, repoName, repositoryID, branch, foundPR) return } s.logger.Warn("exhausted all retries, no PR found after push", @@ -705,7 +709,7 @@ func isMultiBranchSubdir(subdir, repoName string) bool { // (still searching). It updates the watch branch if the agent pushed from a different // branch, then does a single immediate search so we don't wait for the 1-minute poller. func (s *Service) searchPRForExistingWatch( - ctx context.Context, client github.Client, watch *github.PRWatch, + ctx context.Context, workspaceID string, watch *github.PRWatch, sessionID, taskID, branch string, ) { // Update branch if the agent switched branches since the watch was created. @@ -722,7 +726,9 @@ func (s *Service) searchPRForExistingWatch( // Immediate search — if found, update the existing watch and associate. // We must not call associatePRFromPush here because it calls CreatePRWatch, // which would fail with a UNIQUE constraint since the watch already exists. - foundPR, findErr := client.FindPRByBranch(ctx, watch.Owner, watch.Repo, branch) + foundPR, findErr := s.githubService.FindPRByBranchForWorkspace( + ctx, workspaceID, watch.Owner, watch.Repo, branch, + ) if findErr == nil && foundPR != nil { if err := s.githubService.UpdatePRWatchPRNumber(ctx, watch.ID, foundPR.Number); err != nil { s.logger.Warn("failed to update PR watch number", @@ -734,7 +740,9 @@ func (s *Service) searchPRForExistingWatch( // correct per-repo TaskPR row (matters once multi-repo watches exist; // for legacy single-repo watches this is empty and matches the old // "delete all" behavior). - if _, err := s.githubService.AssociatePRWithTask(ctx, taskID, watch.RepositoryID, foundPR); err != nil { + if _, err := s.githubService.AssociatePRWithTaskForWorkspace( + ctx, workspaceID, taskID, watch.RepositoryID, foundPR, + ); err != nil { s.logger.Error("failed to associate PR with task", zap.String("task_id", taskID), zap.Int("pr_number", foundPR.Number), @@ -806,10 +814,14 @@ func (s *Service) ensureSessionPRWatch(ctx context.Context, taskID, sessionID, f if s.githubService == nil { return } + workspaceID := s.taskWorkspaceID(ctx, taskID) + if workspaceID == "" { + return + } targets := s.resolveSessionWatchTargets(ctx, taskID, sessionID, fallbackBranch) for _, t := range targets { - if _, err := s.githubService.EnsurePRWatch( - ctx, sessionID, taskID, t.RepositoryID, t.Owner, t.Repo, t.Branch, + if _, err := s.githubService.EnsurePRWatchForWorkspace( + ctx, workspaceID, sessionID, taskID, t.RepositoryID, t.Owner, t.Repo, t.Branch, ); err != nil { s.logger.Warn("failed to ensure PR watch for session", zap.String("session_id", sessionID), @@ -820,6 +832,14 @@ func (s *Service) ensureSessionPRWatch(ctx context.Context, taskID, sessionID, f } } +func (s *Service) taskWorkspaceID(ctx context.Context, taskID string) string { + task, err := s.repo.GetTask(ctx, taskID) + if err != nil || task == nil { + return "" + } + return strings.TrimSpace(task.WorkspaceID) +} + // sessionWatchTarget describes one (session, repo) pair that should have a // PR watch. Built by walking the task's repositories and matching each to // the session's worktree (branch) and the repo (owner/name). @@ -1071,10 +1091,10 @@ func (s *Service) resolveTaskRepo(ctx context.Context, taskID string) (string, s // pass the per-repo id resolved from the git event's repository_name; the // legacy single-repo path passes the primary task_repository id. func (s *Service) associatePRFromPushScoped( - ctx context.Context, sessionID, taskID, owner, repoName, repositoryID, branch string, pr *github.PR, + ctx context.Context, workspaceID, sessionID, taskID, owner, repoName, repositoryID, branch string, pr *github.PR, ) { - if _, watchErr := s.githubService.CreatePRWatch( - ctx, sessionID, taskID, repositoryID, owner, repoName, pr.Number, branch, + if _, watchErr := s.githubService.CreatePRWatchForWorkspace( + ctx, workspaceID, sessionID, taskID, repositoryID, owner, repoName, pr.Number, branch, ); watchErr != nil { s.logger.Error("failed to create PR watch on push detection", zap.String("session_id", sessionID), @@ -1082,7 +1102,9 @@ func (s *Service) associatePRFromPushScoped( zap.Error(watchErr)) } - if _, assocErr := s.githubService.AssociatePRWithTask(ctx, taskID, repositoryID, pr); assocErr != nil { + if _, assocErr := s.githubService.AssociatePRWithTaskForWorkspace( + ctx, workspaceID, taskID, repositoryID, pr, + ); assocErr != nil { s.logger.Error("failed to associate PR with task on push detection", zap.String("task_id", taskID), zap.String("repository_id", repositoryID), @@ -1123,24 +1145,28 @@ func (s *Service) CheckSessionPR(ctx context.Context, taskID, sessionID string) // Ensure a PR watch exists so the background poller will keep checking repositoryID := s.resolvePrimaryTaskRepositoryID(ctx, taskID) - if _, watchErr := s.githubService.EnsurePRWatch(ctx, sessionID, taskID, repositoryID, owner, repoName, branch); watchErr != nil { + workspaceID := s.taskWorkspaceID(ctx, taskID) + if workspaceID == "" { + return false, github.ErrGitHubWorkspaceRequired + } + if _, watchErr := s.githubService.EnsurePRWatchForWorkspace( + ctx, workspaceID, sessionID, taskID, repositoryID, owner, repoName, branch, + ); watchErr != nil { s.logger.Warn("failed to ensure PR watch during check", zap.String("session_id", sessionID), zap.Error(watchErr)) } // Try to find the PR immediately - client := s.githubService.Client() - if client == nil { - return false, nil - } - pr, findErr := client.FindPRByBranch(ctx, owner, repoName, branch) + pr, findErr := s.githubService.FindPRByBranchForWorkspace( + ctx, workspaceID, owner, repoName, branch, + ) if findErr != nil || pr == nil { return false, nil } // Found a PR — associate it with the task - s.associatePRFromPushScoped(ctx, sessionID, taskID, owner, repoName, repositoryID, branch, pr) + s.associatePRFromPushScoped(ctx, workspaceID, sessionID, taskID, owner, repoName, repositoryID, branch, pr) return true, nil } @@ -1190,6 +1216,7 @@ func (s *Service) buildTaskBranchList(ctx context.Context, store repoStore) ([]g continue } result = append(result, github.TaskBranchInfo{ + WorkspaceID: sess.WorkspaceID, TaskID: sess.TaskID, SessionID: sess.SessionID, RepositoryID: t.RepositoryID, diff --git a/apps/backend/internal/orchestrator/event_handlers_github_ci_automation.go b/apps/backend/internal/orchestrator/event_handlers_github_ci_automation.go index 5922bfdacd..21535bcfe6 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github_ci_automation.go +++ b/apps/backend/internal/orchestrator/event_handlers_github_ci_automation.go @@ -166,7 +166,9 @@ func (s *Service) handleTaskPRCIAutoFix(ctx context.Context, pr *github.TaskPR, if state != nil && state.AutoFixExhaustedAt != nil { return !ciAutomationReadyToMerge(pr) } - feedback, err := s.githubService.GetPRFeedback(ctx, pr.Owner, pr.Repo, pr.PRNumber) + feedback, err := s.githubService.GetPRFeedbackForAutomation( + ctx, pr.WorkspaceID, pr.Owner, pr.Repo, pr.PRNumber, + ) if err != nil { s.recordCIAutomationError(ctx, pr, fmt.Sprintf("fetch PR feedback: %v", err)) return true @@ -302,7 +304,9 @@ func (s *Service) handleTaskPRCIAutoMerge(ctx context.Context, pr *github.TaskPR Signature: signature, AttemptedAt: time.Now().UTC(), } - if err := s.githubService.MergePR(ctx, pr.Owner, pr.Repo, pr.PRNumber, ""); err != nil { + if err := s.githubService.MergePRForAutomation( + ctx, pr.WorkspaceID, pr.Owner, pr.Repo, pr.PRNumber, "", + ); err != nil { s.recordCIAutomationError(ctx, pr, fmt.Sprintf("merge PR: %v", err)) return } diff --git a/apps/backend/internal/orchestrator/event_handlers_github_test.go b/apps/backend/internal/orchestrator/event_handlers_github_test.go index 8842571e94..bffed79cb3 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_github_test.go @@ -45,6 +45,8 @@ type mockGitHubService struct { // path landed on primary by accident). lastCreateWatchRepositoryID string lastAssociateRepositoryID string + lastCreateWatchWorkspaceID string + lastAssociateWorkspaceID string // Review PR reservation tracking. reserveCalls int @@ -88,6 +90,15 @@ type mockGitHubService struct { } func (m *mockGitHubService) Client() github.Client { return m.client } + +func (m *mockGitHubService) FindPRByBranchForWorkspace( + ctx context.Context, _, owner, repo, branch string, +) (*github.PR, error) { + if m.client == nil { + return nil, nil + } + return m.client.FindPRByBranch(ctx, owner, repo, branch) +} func (m *mockGitHubService) GetTaskPR(_ context.Context, _ string) (*github.TaskPR, error) { m.getTaskPRCalls++ return m.taskPR, m.taskPRErr @@ -208,15 +219,33 @@ func (m *mockGitHubService) GetPRFeedback(context.Context, string, string, int) } return &github.PRFeedback{}, nil } + +func (m *mockGitHubService) GetPRFeedbackForAutomation( + ctx context.Context, _, owner, repo string, number int, +) (*github.PRFeedback, error) { + return m.GetPRFeedback(ctx, owner, repo, number) +} func (m *mockGitHubService) MergePR(context.Context, string, string, int, string) error { m.mergeCalls++ return m.mergeErr } + +func (m *mockGitHubService) MergePRForAutomation( + ctx context.Context, _, owner, repo string, number int, method string, +) error { + return m.MergePR(ctx, owner, repo, number, method) +} func (m *mockGitHubService) EnsurePRWatch(_ context.Context, _, _, _, _, _, branch string) (*github.PRWatch, error) { m.ensureWatchCalls++ m.ensureWatchBranch = branch return &github.PRWatch{}, nil } + +func (m *mockGitHubService) EnsurePRWatchForWorkspace( + ctx context.Context, _, sessionID, taskID, repositoryID, owner, repo, branch string, +) (*github.PRWatch, error) { + return m.EnsurePRWatch(ctx, sessionID, taskID, repositoryID, owner, repo, branch) +} func (m *mockGitHubService) GetPRWatchBySession(_ context.Context, _ string) (*github.PRWatch, error) { return m.prWatch, nil } @@ -232,11 +261,23 @@ func (m *mockGitHubService) CreatePRWatch(_ context.Context, _, _, repositoryID, m.lastCreateWatchRepositoryID = repositoryID return &github.PRWatch{}, nil } +func (m *mockGitHubService) CreatePRWatchForWorkspace( + ctx context.Context, workspaceID, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string, +) (*github.PRWatch, error) { + m.lastCreateWatchWorkspaceID = workspaceID + return m.CreatePRWatch(ctx, sessionID, taskID, repositoryID, owner, repo, prNumber, branch) +} func (m *mockGitHubService) AssociatePRWithTask(_ context.Context, _, repositoryID string, _ *github.PR) (*github.TaskPR, error) { m.associateCalls++ m.lastAssociateRepositoryID = repositoryID return &github.TaskPR{}, nil } +func (m *mockGitHubService) AssociatePRWithTaskForWorkspace( + ctx context.Context, workspaceID, taskID, repositoryID string, pr *github.PR, +) (*github.TaskPR, error) { + m.lastAssociateWorkspaceID = workspaceID + return m.AssociatePRWithTask(ctx, taskID, repositoryID, pr) +} func (m *mockGitHubService) UpdatePRWatchBranchIfSearching(_ context.Context, _, branch string) error { m.updateBranchCalls++ m.updatedBranch = branch @@ -629,6 +670,12 @@ func TestDetectPushAndAssociatePR(t *testing.T) { if ghSvc.createWatchCalls != 1 { t.Errorf("expected 1 CreatePRWatch call, got %d", ghSvc.createWatchCalls) } + if ghSvc.lastCreateWatchWorkspaceID != "ws1" { + t.Errorf("CreatePRWatch workspace = %q, want ws1", ghSvc.lastCreateWatchWorkspaceID) + } + if ghSvc.lastAssociateWorkspaceID != "ws1" { + t.Errorf("AssociatePRWithTask workspace = %q, want ws1", ghSvc.lastAssociateWorkspaceID) + } }) t.Run("skips when watch has pr_number > 0", func(t *testing.T) { @@ -675,6 +722,9 @@ func TestDetectPushAndAssociatePR(t *testing.T) { if ghSvc.createWatchCalls != 0 { t.Errorf("expected no CreatePRWatch calls (watch already exists), got %d", ghSvc.createWatchCalls) } + if ghSvc.lastAssociateWorkspaceID != "ws1" { + t.Errorf("AssociatePRWithTask workspace = %q, want ws1", ghSvc.lastAssociateWorkspaceID) + } }) t.Run("updates watch branch when agent pushes from different branch", func(t *testing.T) { diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 42d85662ee..d3442cc289 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -615,6 +615,7 @@ func seedSession(t *testing.T, repo *sqliterepo.Repository, taskID, sessionID, w // Create task task := &models.Task{ ID: taskID, + WorkspaceID: "ws1", WorkflowID: "wf1", WorkflowStepID: workflowStepID, Title: "Test Task", diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 1b8229c92d..f79580bd0a 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -628,6 +628,9 @@ type Executor struct { gitlabCredentials GitLabCredentialResolver logger *logger.Logger + githubCredentialIssuer GitHubCredentialLeaseIssuer + githubCredentialBrokerURL string + // Configuration retryLimit int retryDelay time.Duration @@ -706,35 +709,33 @@ func (e *Executor) taskEnvLock(taskID string) *sync.Mutex { // RepoCloner clones remote repositories to local disk. type RepoCloner interface { - EnsureClonedForProvider( - ctx context.Context, - cloneURL, provider, providerHost, owner, name, credentialHost, token string, + EnsureWorkspaceClonedForProvider( + ctx context.Context, workspaceID, cloneURL, provider, providerHost, + owner, name, credentialHost, token string, ) (string, error) + ShouldRecloneForWorkspace(workspaceID, path string) bool // BuildCloneURL constructs a protocol-aware clone URL for the given provider/owner/name. BuildCloneURLWithHost(provider, host, owner, name string) (string, error) } type authenticatedRepoCloner interface { - EnsureClonedWithBasicAuth(ctx context.Context, cloneURL, owner, name, username, password string) (string, error) + EnsureWorkspaceClonedWithBasicAuth( + ctx context.Context, workspaceID, provider, providerHost, + cloneURL, owner, name, username, password string, + ) (string, error) } func (e *Executor) ensureClonedWithWorkspaceAuth( ctx context.Context, repo *models.Repository, cloneURL string, ) (string, error) { - if strings.EqualFold(repo.Provider, "gitlab") { - credentialHost, token := "", "" - if e.gitlabCredentials != nil { - credentialHost, token, _ = e.gitlabCredentials.ResolveGitLabExecutionCredentials(ctx, repo.WorkspaceID) - } - return e.repoCloner.EnsureClonedForProvider( - ctx, cloneURL, repo.Provider, repo.ProviderHost, - repo.ProviderOwner, repo.ProviderName, credentialHost, token, - ) + credentialHost, token := "", "" + if strings.EqualFold(repo.Provider, "gitlab") && e.gitlabCredentials != nil { + credentialHost, token, _ = e.gitlabCredentials.ResolveGitLabExecutionCredentials(ctx, repo.WorkspaceID) } if repo.Provider != "azure_devops" || !strings.HasPrefix(cloneURL, "https://") { - return e.repoCloner.EnsureClonedForProvider( - ctx, cloneURL, repo.Provider, repo.ProviderHost, - repo.ProviderOwner, repo.ProviderName, "", "", + return e.repoCloner.EnsureWorkspaceClonedForProvider( + ctx, repo.WorkspaceID, cloneURL, repo.Provider, repo.ProviderHost, + repo.ProviderOwner, repo.ProviderName, credentialHost, token, ) } authCloner, ok := e.repoCloner.(authenticatedRepoCloner) @@ -746,7 +747,10 @@ func (e *Executor) ensureClonedWithWorkspaceAuth( return "", fmt.Errorf("read Azure DevOps clone credential: %w", err) } // Azure DevOps PAT authentication ignores the username; any non-empty value works. - return authCloner.EnsureClonedWithBasicAuth(ctx, cloneURL, repo.ProviderOwner, repo.ProviderName, "kandev", pat) + return authCloner.EnsureWorkspaceClonedWithBasicAuth( + ctx, repo.WorkspaceID, repo.Provider, repo.ProviderHost, + cloneURL, repo.ProviderOwner, repo.ProviderName, "kandev", pat, + ) } // RepoUpdater updates repository records in the database. diff --git a/apps/backend/internal/orchestrator/executor/executor_credentials.go b/apps/backend/internal/orchestrator/executor/executor_credentials.go index 5ae39d79bf..c7d5ab07ec 100644 --- a/apps/backend/internal/orchestrator/executor/executor_credentials.go +++ b/apps/backend/internal/orchestrator/executor/executor_credentials.go @@ -3,33 +3,191 @@ package executor import ( "context" "encoding/json" + "errors" "fmt" + "net" "net/url" - "os/exec" "strconv" "strings" - "time" "go.uber.org/zap" - "github.com/kandev/kandev/internal/common/subproc" + "github.com/kandev/kandev/internal/githubauth" ) const ( // envGitHubToken is the environment variable name for GitHub authentication tokens. envGitHubToken = "GITHUB_TOKEN" // envGHToken is the gh CLI compatible environment variable name. - envGHToken = "GH_TOKEN" - envGitLabToken = "GITLAB_TOKEN" - envGitLabHost = "GITLAB_HOST" - envKandevGitLabHost = "KANDEV_GITLAB_HOST" + envGHToken = "GH_TOKEN" + envGitLabToken = "GITLAB_TOKEN" + envGitLabHost = "GITLAB_HOST" + envKandevGitLabHost = "KANDEV_GITLAB_HOST" + + gitHubCredentialHelper = "!agentctl git-credential" + defaultGitHubHost = "github.com" gitLabCredentialHelper = `!f() { echo "username=oauth2"; echo "password=$GITLAB_TOKEN"; }; f` ) -// injectGitLabWorkspaceCredentials overrides any inherited/profile GitLab -// credential with the current workspace's configured connection. Empty -// values are deliberate: standalone executions otherwise inherit the backend -// process environment and could receive another workspace's fallback token. +var ErrGitHubCredentialBrokerURL = errors.New("invalid GitHub credential broker URL") + +type GitHubCredentialLeaseRequest struct { + WorkspaceID string + TaskID string + SessionID string + RepositoryID string + Owner string + Repo string + Host string +} + +type GitHubCredentialLease struct { + Token string +} + +type githubCredentialScope struct { + Lease string `json:"lease"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + RepositoryID string `json:"repository_id"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Host string `json:"host"` +} + +type GitHubCredentialLeaseIssuer interface { + IssueGitHubCredentialLease(context.Context, GitHubCredentialLeaseRequest) (GitHubCredentialLease, error) +} + +// SetGitHubCredentialBroker configures renewable workspace automation credentials. +// brokerURL is the full credential-resolution endpoint URL. +func (e *Executor) SetGitHubCredentialBroker(issuer GitHubCredentialLeaseIssuer, brokerURL string) { + e.githubCredentialIssuer = issuer + e.githubCredentialBrokerURL = strings.TrimSpace(brokerURL) +} + +func (e *Executor) configureGitHubCredentialBroker( + ctx context.Context, + req *LaunchAgentRequest, + info *repoInfo, +) error { + return e.configureGitHubCredentialBrokerForRepositories(ctx, req, []*repoInfo{info}) +} + +func (e *Executor) configureGitHubCredentialBrokerForRepositories( + ctx context.Context, + req *LaunchAgentRequest, + infos []*repoInfo, +) error { + if e.githubCredentialIssuer == nil || len(infos) == 0 { + return nil + } + if req.Env == nil { + req.Env = make(map[string]string) + } + if req.Env[envGitHubToken] != "" || req.Env[envGHToken] != "" { + return nil + } + scopes := make([]githubCredentialScope, 0, len(infos)) + for _, info := range infos { + scope, err := e.issueGitHubCredentialScope(ctx, req, info) + if err != nil { + return err + } + if scope != nil { + scopes = append(scopes, *scope) + } + } + if len(scopes) == 0 { + return nil + } + encodedScopes, err := json.Marshal(scopes) + if err != nil { + return fmt.Errorf("encode GitHub credential scopes: %w", err) + } + primary := scopes[0] + req.Env[githubauth.CredentialBrokerURLEnv] = e.githubCredentialBrokerURL + req.Env[githubauth.CredentialLeaseEnv] = primary.Lease + req.Env[githubauth.CredentialTaskIDEnv] = primary.TaskID + req.Env[githubauth.CredentialSessionIDEnv] = primary.SessionID + req.Env[githubauth.CredentialRepositoryEnv] = primary.RepositoryID + req.Env[githubauth.CredentialOwnerEnv] = primary.Owner + req.Env[githubauth.CredentialRepoEnv] = primary.Repo + req.Env[githubauth.CredentialHostEnv] = primary.Host + req.Env[githubauth.CredentialScopesEnv] = string(encodedScopes) + req.Env["GIT_TERMINAL_PROMPT"] = "0" + appendGitConfig(req.Env, "credential.https://github.com.helper", gitHubCredentialHelper) + appendGitConfig(req.Env, "credential.useHttpPath", "true") + return nil +} + +func (e *Executor) issueGitHubCredentialScope( + ctx context.Context, + req *LaunchAgentRequest, + info *repoInfo, +) (*githubCredentialScope, error) { + if info == nil || info.Repository == nil { + return nil, nil + } + repository := info.Repository + if repository.Provider != "" && !strings.EqualFold(repository.Provider, "github") { + return nil, nil + } + owner := strings.TrimSpace(repository.ProviderOwner) + repo := strings.TrimSpace(repository.ProviderName) + if owner == "" || repo == "" || info.RepositoryID == "" { + return nil, nil + } + if err := validateGitHubCredentialBrokerURL(e.githubCredentialBrokerURL, req.ExecutorType); err != nil { + return nil, err + } + lease, err := e.githubCredentialIssuer.IssueGitHubCredentialLease(ctx, GitHubCredentialLeaseRequest{ + WorkspaceID: req.WorkspaceID, TaskID: req.TaskID, SessionID: req.SessionID, + RepositoryID: info.RepositoryID, Owner: owner, Repo: repo, Host: defaultGitHubHost, + }) + if err != nil { + return nil, fmt.Errorf("issue GitHub credential lease: %w", err) + } + if strings.TrimSpace(lease.Token) == "" { + return nil, fmt.Errorf("issue GitHub credential lease: empty lease") + } + return &githubCredentialScope{ + Lease: lease.Token, TaskID: req.TaskID, SessionID: req.SessionID, + RepositoryID: info.RepositoryID, Owner: owner, Repo: repo, Host: defaultGitHubHost, + }, nil +} + +func validateGitHubCredentialBrokerURL(raw, executorType string) error { + parsed, err := url.ParseRequestURI(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" { + return fmt.Errorf("%w: absolute endpoint is required", ErrGitHubCredentialBrokerURL) + } + if parsed.Scheme == "https" { + return nil + } + if parsed.Scheme != "http" || executorNeedsResolvedCredentials(executorType) || !isLoopbackHost(parsed.Hostname()) { + return fmt.Errorf("%w: HTTPS is required for non-local executors", ErrGitHubCredentialBrokerURL) + } + return nil +} + +func isLoopbackHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func appendGitConfig(env map[string]string, key, value string) { + count, _ := strconv.Atoi(env["GIT_CONFIG_COUNT"]) + env[fmt.Sprintf("GIT_CONFIG_KEY_%d", count)] = key + env[fmt.Sprintf("GIT_CONFIG_VALUE_%d", count)] = value + env["GIT_CONFIG_COUNT"] = strconv.Itoa(count + 1) +} + +// injectGitLabWorkspaceCredentials overrides inherited GitLab credentials +// with the current workspace's configured connection. func (e *Executor) injectGitLabWorkspaceCredentials(ctx context.Context, req *LaunchAgentRequest) { if req.Env == nil { req.Env = make(map[string]string) @@ -73,14 +231,15 @@ func removeGitLabCredentialHelpers(env map[string]string) { } if len(entries) == 0 { delete(env, "GIT_CONFIG_COUNT") - } else { - env["GIT_CONFIG_COUNT"] = strconv.Itoa(len(entries)) + return } + env["GIT_CONFIG_COUNT"] = strconv.Itoa(len(entries)) } func appendGitLabCredentialHelper(env map[string]string, host string) { origin, err := url.Parse(strings.TrimSpace(host)) - if err != nil || (origin.Scheme != "http" && origin.Scheme != "https") || origin.Host == "" || origin.User != nil || (origin.Path != "" && origin.Path != "/") { + if err != nil || (origin.Scheme != "http" && origin.Scheme != "https") || origin.Host == "" || + origin.User != nil || (origin.Path != "" && origin.Path != "/") { return } origin.Path = "" @@ -112,94 +271,13 @@ func cloneStringMap(values map[string]string) map[string]string { return cloned } -// injectGitHubToken injects GITHUB_TOKEN and GH_TOKEN into the request env for remote executors. -// It looks for a GITHUB_TOKEN secret in the secrets store and injects it if found. -// Both env vars are set for compatibility: GITHUB_TOKEN for git, GH_TOKEN for gh CLI. -func (e *Executor) injectGitHubToken(ctx context.Context, req *LaunchAgentRequest) { - if req.Env == nil { - req.Env = make(map[string]string) - } - - // Skip if already set (user configured explicitly in profile) - if req.Env[envGitHubToken] != "" || req.Env[envGHToken] != "" { - return - } - - if e.secretStore == nil { - return - } - - // Look for a GITHUB_TOKEN secret in the secrets store - items, err := e.secretStore.List(ctx) - if err != nil { - e.logger.Debug("failed to list secrets for GitHub token injection", zap.Error(err)) - return - } - - var tokenSecretID string - for _, item := range items { - if !item.HasValue { - continue - } - if item.Name == envGitHubToken || item.Name == "github_token" { - tokenSecretID = item.ID - break - } - } - if tokenSecretID == "" { - return - } - - token, err := e.secretStore.Reveal(ctx, tokenSecretID) - if err != nil || token == "" { - e.logger.Debug("failed to reveal GitHub token secret", zap.Error(err)) - return - } - - // Inject both env vars for compatibility - req.Env[envGitHubToken] = token - req.Env[envGHToken] = token - e.logger.Debug("injected GitHub token for remote executor") -} - -// injectGitHubTokenFromCLI is the final fallback that extracts a token from -// the local gh CLI if no other credential source provided a GitHub token. -// This enables "just works" behavior when the user has gh authenticated locally. -func (e *Executor) injectGitHubTokenFromCLI(_ context.Context, req *LaunchAgentRequest) { - if req.Env == nil { - req.Env = make(map[string]string) - } - - // Skip if already set by any previous method - if req.Env[envGitHubToken] != "" || req.Env[envGHToken] != "" { - return - } - - // Try to extract token from local gh CLI - token, err := detectGHToken() - if err != nil { - e.logger.Debug("no GitHub token from local gh CLI", zap.Error(err)) - return - } - - req.Env[envGitHubToken] = token - req.Env[envGHToken] = token - e.logger.Debug("injected GitHub token from local gh CLI") -} - -// resolveRemoteCredentials handles profile-level remote auth credentials. -// It resolves remote_auth_secrets (secret-based env vars like gh_cli_env) -// and remote_credentials (including gh_cli_token which extracts from local gh CLI). +// resolveRemoteCredentials handles explicit profile-level remote auth secrets. func (e *Executor) resolveRemoteCredentials(ctx context.Context, req *LaunchAgentRequest, metadata map[string]interface{}) { if req.Env == nil { req.Env = make(map[string]string) } - // 1. Resolve remote_auth_secrets (e.g., gh_cli_env method with a secret ID) e.resolveAuthSecrets(ctx, req, metadata) - - // 2. Handle gh_cli_token from remote_credentials (extract from local gh CLI) - e.resolveGHCLIToken(req, metadata) } // resolveAuthSecrets reads remote_auth_secrets from metadata and resolves secret values @@ -266,75 +344,3 @@ func methodIDToEnvVar(methodID string) string { return "" } } - -// resolveGHCLIToken handles gh_cli_token from remote_credentials metadata. -// If selected, it extracts the token from the local gh CLI via `gh auth token`. -func (e *Executor) resolveGHCLIToken(req *LaunchAgentRequest, metadata map[string]interface{}) { - // Skip if either GITHUB_TOKEN or GH_TOKEN is already set (respect profile env vars priority) - if req.Env[envGitHubToken] != "" || req.Env[envGHToken] != "" { - return - } - - credsJSON, _ := metadata[profileKeyRemoteCredentials].(string) - if credsJSON == "" { - return - } - - var credentialIDs []string - if err := json.Unmarshal([]byte(credsJSON), &credentialIDs); err != nil { - e.logger.Debug("failed to parse remote_credentials", zap.Error(err)) - return - } - - // Check if gh_cli_token is selected - hasGHToken := false - for _, id := range credentialIDs { - if id == "gh_cli_token" { - hasGHToken = true - break - } - } - if !hasGHToken { - return - } - - // Extract token from local gh CLI - token, err := detectGHToken() - if err != nil { - e.logger.Debug("failed to detect gh token from local CLI", zap.Error(err)) - return - } - - req.Env[envGitHubToken] = token - req.Env[envGHToken] = token - e.logger.Debug("resolved GITHUB_TOKEN from local gh CLI") -} - -// detectGHToken runs `gh auth token` to extract the GitHub token from the local gh CLI. -// -// Throttle Acquire (30s budget) runs first; the 5s exec ctx is constructed -// AFTER the slot is held so its deadline starts from when gh actually -// begins running. Building it earlier would let throttle queue time eat -// into the exec budget and silently skip GITHUB_TOKEN injection. -func detectGHToken() (string, error) { - acquireCtx, cancelAcquire := context.WithTimeout(context.Background(), 30*time.Second) - defer cancelAcquire() - release, err := subproc.GH().Acquire(acquireCtx) - if err != nil { - return "", fmt.Errorf("gh throttle acquire: %w", err) - } - defer release() - execCtx, cancelExec := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelExec() - - cmd := exec.CommandContext(execCtx, "gh", "auth", "token") - out, err := cmd.Output() - if err != nil { - return "", err - } - token := strings.TrimSpace(string(out)) - if token == "" { - return "", fmt.Errorf("gh auth token returned empty") - } - return token, nil -} diff --git a/apps/backend/internal/orchestrator/executor/executor_credentials_test.go b/apps/backend/internal/orchestrator/executor/executor_credentials_test.go index e0afa827f6..820b9ff4f1 100644 --- a/apps/backend/internal/orchestrator/executor/executor_credentials_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_credentials_test.go @@ -2,15 +2,223 @@ package executor import ( "context" + "encoding/json" + "errors" "fmt" + "os" + "path/filepath" "strings" "testing" + "github.com/kandev/kandev/internal/githubauth" "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) +const ( + envGitHubCredentialBrokerURL = githubauth.CredentialBrokerURLEnv + envGitHubCredentialLease = githubauth.CredentialLeaseEnv + envGitHubCredentialTaskID = githubauth.CredentialTaskIDEnv + envGitHubCredentialSessionID = githubauth.CredentialSessionIDEnv + envGitHubCredentialRepository = githubauth.CredentialRepositoryEnv + envGitHubCredentialOwner = githubauth.CredentialOwnerEnv + envGitHubCredentialRepo = githubauth.CredentialRepoEnv + envGitHubCredentialHost = githubauth.CredentialHostEnv + envGitHubCredentialScopes = githubauth.CredentialScopesEnv +) + +type fakeGitHubCredentialLeaseIssuer struct { + request GitHubCredentialLeaseRequest + requests []GitHubCredentialLeaseRequest + lease GitHubCredentialLease + err error + calls int +} + +func (f *fakeGitHubCredentialLeaseIssuer) IssueGitHubCredentialLease( + _ context.Context, + req GitHubCredentialLeaseRequest, +) (GitHubCredentialLease, error) { + f.calls++ + f.request = req + f.requests = append(f.requests, req) + return f.lease, f.err +} + +func TestConfigureGitHubCredentialBroker(t *testing.T) { + issuer := &fakeGitHubCredentialLeaseIssuer{lease: GitHubCredentialLease{Token: "opaque-lease"}} + exec := newTestExecutor(t, &mockAgentManager{}, newMockRepository()) + exec.SetGitHubCredentialBroker(issuer, "https://kandev.example/api/github/credentials/resolve") + req := &LaunchAgentRequest{ + TaskID: "task-1", + WorkspaceID: "workspace-1", + SessionID: "session-1", + ExecutorType: string(models.ExecutorTypeRemoteDocker), + Env: map[string]string{ + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "http.version", + "GIT_CONFIG_VALUE_0": "HTTP/1.1", + }, + } + info := &repoInfo{ + RepositoryID: "repo-1", + Repository: &models.Repository{ + Provider: "github", + ProviderOwner: "acme", + ProviderName: "widgets", + }, + } + + err := exec.configureGitHubCredentialBroker(context.Background(), req, info) + if err != nil { + t.Fatalf("configureGitHubCredentialBroker() error = %v", err) + } + if issuer.calls != 1 { + t.Fatalf("IssueGitHubCredentialLease calls = %d, want 1", issuer.calls) + } + if issuer.request.WorkspaceID != "workspace-1" || issuer.request.RepositoryID != "repo-1" { + t.Fatalf("lease scope = %+v", issuer.request) + } + wantEnv := map[string]string{ + envGitHubCredentialBrokerURL: "https://kandev.example/api/github/credentials/resolve", + envGitHubCredentialLease: "opaque-lease", + envGitHubCredentialTaskID: "task-1", + envGitHubCredentialSessionID: "session-1", + envGitHubCredentialRepository: "repo-1", + envGitHubCredentialOwner: "acme", + envGitHubCredentialRepo: "widgets", + envGitHubCredentialHost: "github.com", + "GIT_CONFIG_COUNT": "3", + "GIT_CONFIG_KEY_1": "credential.https://github.com.helper", + "GIT_CONFIG_VALUE_1": "!agentctl git-credential", + "GIT_CONFIG_KEY_2": "credential.useHttpPath", + "GIT_CONFIG_VALUE_2": "true", + "GIT_TERMINAL_PROMPT": "0", + } + if got := req.Env[envGitHubCredentialScopes]; !strings.Contains(got, `"repository_id":"repo-1"`) { + t.Fatalf("credential scopes = %q, want repository scope", got) + } + for key, want := range wantEnv { + if got := req.Env[key]; got != want { + t.Errorf("Env[%q] = %q, want %q", key, got, want) + } + } + if _, ok := req.Env[envGitHubToken]; ok { + t.Error("managed credential configuration exposed GITHUB_TOKEN") + } + if _, ok := req.Env[envGHToken]; ok { + t.Error("managed credential configuration exposed GH_TOKEN") + } +} + +func TestConfigureGitHubCredentialBrokerIssuesOneLeasePerRepository(t *testing.T) { + issuer := &fakeGitHubCredentialLeaseIssuer{} + exec := newTestExecutor(t, &mockAgentManager{}, newMockRepository()) + exec.SetGitHubCredentialBroker(issuer, "https://kandev.example/api/github/credentials/resolve") + req := &LaunchAgentRequest{ + TaskID: "task-1", WorkspaceID: "workspace-1", SessionID: "session-1", + ExecutorType: string(models.ExecutorTypeRemoteDocker), Env: map[string]string{}, + } + infos := []*repoInfo{ + {RepositoryID: "repo-1", Repository: &models.Repository{ + Provider: "github", ProviderOwner: "acme", ProviderName: "frontend", + }}, + {RepositoryID: "repo-2", Repository: &models.Repository{ + Provider: "github", ProviderOwner: "acme", ProviderName: "backend", + }}, + } + issuer.lease = GitHubCredentialLease{Token: "opaque-lease"} + + if err := exec.configureGitHubCredentialBrokerForRepositories(context.Background(), req, infos); err != nil { + t.Fatalf("configureGitHubCredentialBrokerForRepositories() error = %v", err) + } + if len(issuer.requests) != 2 { + t.Fatalf("lease requests = %d, want 2", len(issuer.requests)) + } + if issuer.requests[0].RepositoryID != "repo-1" || issuer.requests[1].RepositoryID != "repo-2" { + t.Fatalf("lease scopes = %+v", issuer.requests) + } + var scopes []githubCredentialScope + if err := json.Unmarshal([]byte(req.Env[envGitHubCredentialScopes]), &scopes); err != nil { + t.Fatalf("decode credential scopes: %v", err) + } + if len(scopes) != 2 || scopes[0].Repo != "frontend" || scopes[1].Repo != "backend" { + t.Fatalf("credential scopes = %+v", scopes) + } + if got := req.Env[envGitHubCredentialRepo]; got != "frontend" { + t.Fatalf("primary gh scope = %q, want frontend", got) + } +} + +func TestConfigureGitHubCredentialBrokerPreservesExplicitProfileToken(t *testing.T) { + issuer := &fakeGitHubCredentialLeaseIssuer{lease: GitHubCredentialLease{Token: "opaque-lease"}} + exec := newTestExecutor(t, &mockAgentManager{}, newMockRepository()) + exec.SetGitHubCredentialBroker(issuer, "https://kandev.example/api/github/credentials/resolve") + req := &LaunchAgentRequest{ + TaskID: "task-1", WorkspaceID: "workspace-1", SessionID: "session-1", + ExecutorType: string(models.ExecutorTypeRemoteDocker), + Env: map[string]string{envGHToken: "profile-token"}, + } + info := &repoInfo{RepositoryID: "repo-1", Repository: &models.Repository{ + Provider: "github", ProviderOwner: "acme", ProviderName: "widgets", + }} + + if err := exec.configureGitHubCredentialBroker(context.Background(), req, info); err != nil { + t.Fatalf("configureGitHubCredentialBroker() error = %v", err) + } + if issuer.calls != 0 { + t.Fatalf("IssueGitHubCredentialLease calls = %d, want 0", issuer.calls) + } + if got := req.Env[envGHToken]; got != "profile-token" { + t.Fatalf("GH_TOKEN = %q, want explicit profile value", got) + } + if got := req.Env[envGitHubCredentialLease]; got != "" { + t.Fatalf("broker lease = %q, want none with explicit profile auth", got) + } +} + +func TestConfigureGitHubCredentialBrokerRejectsRemoteLoopbackURL(t *testing.T) { + issuer := &fakeGitHubCredentialLeaseIssuer{lease: GitHubCredentialLease{Token: "opaque-lease"}} + exec := newTestExecutor(t, &mockAgentManager{}, newMockRepository()) + exec.SetGitHubCredentialBroker(issuer, "http://127.0.0.1:8080/api/github/credentials/resolve") + req := &LaunchAgentRequest{ + TaskID: "task-1", WorkspaceID: "workspace-1", SessionID: "session-1", + ExecutorType: string(models.ExecutorTypeSSH), Env: map[string]string{}, + } + info := &repoInfo{RepositoryID: "repo-1", Repository: &models.Repository{ + Provider: "github", ProviderOwner: "acme", ProviderName: "widgets", + }} + + err := exec.configureGitHubCredentialBroker(context.Background(), req, info) + if err == nil || !errors.Is(err, ErrGitHubCredentialBrokerURL) { + t.Fatalf("configureGitHubCredentialBroker() error = %v, want broker URL error", err) + } + if issuer.calls != 0 { + t.Fatalf("IssueGitHubCredentialLease calls = %d, want 0", issuer.calls) + } +} + +func TestConfigureGitHubCredentialBrokerAllowsLocalLoopbackURL(t *testing.T) { + issuer := &fakeGitHubCredentialLeaseIssuer{lease: GitHubCredentialLease{Token: "opaque-lease"}} + exec := newTestExecutor(t, &mockAgentManager{}, newMockRepository()) + exec.SetGitHubCredentialBroker(issuer, "http://localhost:8080/api/github/credentials/resolve") + req := &LaunchAgentRequest{ + TaskID: "task-1", WorkspaceID: "workspace-1", SessionID: "session-1", + ExecutorType: string(models.ExecutorTypeWorktree), Env: map[string]string{}, + } + info := &repoInfo{RepositoryID: "repo-1", Repository: &models.Repository{ + Provider: "github", ProviderOwner: "acme", ProviderName: "widgets", + }} + + if err := exec.configureGitHubCredentialBroker(context.Background(), req, info); err != nil { + t.Fatalf("configureGitHubCredentialBroker() error = %v", err) + } + if issuer.calls != 1 { + t.Fatalf("IssueGitHubCredentialLease calls = %d, want 1", issuer.calls) + } +} + type fakeGitLabCredentialResolver struct { byWorkspace map[string]struct{ host, token string } } @@ -264,84 +472,7 @@ func TestResolveAuthSecrets(t *testing.T) { } } -func TestResolveGHCLIToken(t *testing.T) { - repo := newMockRepository() - agentManager := &mockAgentManager{} - executor := newTestExecutor(t, agentManager, repo) - - tests := []struct { - name string - metadata map[string]interface{} - existingEnv map[string]string - expectSkip bool - }{ - { - name: "no remote_credentials", - metadata: map[string]interface{}{}, - expectSkip: true, - }, - { - name: "gh_cli_token not in list", - metadata: map[string]interface{}{ - "remote_credentials": `["other_credential"]`, - }, - expectSkip: true, - }, - { - name: "skip if GITHUB_TOKEN already set", - metadata: map[string]interface{}{ - "remote_credentials": `["gh_cli_token"]`, - }, - existingEnv: map[string]string{ - envGitHubToken: "existing", - }, - expectSkip: true, - }, - { - name: "skip if GH_TOKEN already set", - metadata: map[string]interface{}{ - "remote_credentials": `["gh_cli_token"]`, - }, - existingEnv: map[string]string{ - envGHToken: "existing-gh-token", - }, - expectSkip: true, - }, - { - name: "invalid JSON ignored", - metadata: map[string]interface{}{ - "remote_credentials": `{invalid}`, - }, - expectSkip: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := &LaunchAgentRequest{ - Env: tt.existingEnv, - } - if req.Env == nil { - req.Env = make(map[string]string) - } - initialGHToken := req.Env[envGitHubToken] - initialGH := req.Env[envGHToken] - - executor.resolveGHCLIToken(req, tt.metadata) - - if tt.expectSkip { - if req.Env[envGitHubToken] != initialGHToken { - t.Errorf("expected GITHUB_TOKEN to remain %q, got %q", initialGHToken, req.Env[envGitHubToken]) - } - if req.Env[envGHToken] != initialGH { - t.Errorf("expected GH_TOKEN to remain %q, got %q", initialGH, req.Env[envGHToken]) - } - } - }) - } -} - -func TestInjectGitHubToken(t *testing.T) { +func TestApplyContainerCredentialsDoesNotInjectGlobalGitHubToken(t *testing.T) { repo := newMockRepository() agentManager := &mockAgentManager{} executor := newTestExecutor(t, agentManager, repo) @@ -351,54 +482,36 @@ func TestInjectGitHubToken(t *testing.T) { names: map[string]string{"secret-1": envGitHubToken}, } - t.Run("injects token when not set", func(t *testing.T) { - req := &LaunchAgentRequest{Env: make(map[string]string)} - executor.injectGitHubToken(context.Background(), req) - if req.Env[envGitHubToken] != "ghp_globaltoken" { - t.Errorf("expected GITHUB_TOKEN to be set, got %q", req.Env[envGitHubToken]) - } - if req.Env[envGHToken] != "ghp_globaltoken" { - t.Errorf("expected GH_TOKEN to be set, got %q", req.Env[envGHToken]) - } - }) - - t.Run("skips if GITHUB_TOKEN already set", func(t *testing.T) { - req := &LaunchAgentRequest{Env: map[string]string{envGitHubToken: "existing-token"}} - executor.injectGitHubToken(context.Background(), req) - if req.Env[envGitHubToken] != "existing-token" { - t.Errorf("expected GITHUB_TOKEN to remain unchanged, got %q", req.Env[envGitHubToken]) - } - }) - - t.Run("skips if GH_TOKEN already set", func(t *testing.T) { - req := &LaunchAgentRequest{Env: map[string]string{envGHToken: "existing-gh-token"}} - executor.injectGitHubToken(context.Background(), req) - if req.Env[envGitHubToken] != "" { - t.Errorf("expected GITHUB_TOKEN to not be injected when GH_TOKEN is set, got %q", req.Env[envGitHubToken]) - } - }) + req := &LaunchAgentRequest{Env: make(map[string]string)} + executor.applyContainerCredentials(context.Background(), req, nil) + if got := req.Env[envGitHubToken]; got != "" { + t.Fatalf("GITHUB_TOKEN = %q, want no installation-wide fallback", got) + } + if got := req.Env[envGHToken]; got != "" { + t.Fatalf("GH_TOKEN = %q, want no installation-wide fallback", got) + } } -func TestInjectGitHubTokenFromCLI(t *testing.T) { - repo := newMockRepository() - agentManager := &mockAgentManager{} - executor := newTestExecutor(t, agentManager, repo) +func TestApplyContainerCredentialsDoesNotExtractAmbientGitHubCLIAccount(t *testing.T) { + binDir := t.TempDir() + ghPath := filepath.Join(binDir, "gh") + if err := os.WriteFile(ghPath, []byte("#!/bin/sh\nprintf ambient-host-token\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + executor := newTestExecutor(t, &mockAgentManager{}, newMockRepository()) + req := &LaunchAgentRequest{Env: make(map[string]string)} - t.Run("skips if GITHUB_TOKEN already set", func(t *testing.T) { - req := &LaunchAgentRequest{Env: map[string]string{envGitHubToken: "existing-token"}} - executor.injectGitHubTokenFromCLI(context.Background(), req) - if req.Env[envGitHubToken] != "existing-token" { - t.Errorf("expected GITHUB_TOKEN to remain unchanged, got %q", req.Env[envGitHubToken]) - } + executor.applyContainerCredentials(context.Background(), req, map[string]interface{}{ + profileKeyRemoteCredentials: `["gh_cli_token"]`, }) - t.Run("skips if GH_TOKEN already set", func(t *testing.T) { - req := &LaunchAgentRequest{Env: map[string]string{envGHToken: "existing-gh-token"}} - executor.injectGitHubTokenFromCLI(context.Background(), req) - if req.Env[envGHToken] != "existing-gh-token" { - t.Errorf("expected GH_TOKEN to remain unchanged, got %q", req.Env[envGHToken]) - } - }) + if got := req.Env[envGitHubToken]; got != "" { + t.Fatalf("GITHUB_TOKEN = %q, want no ambient gh credential", got) + } + if got := req.Env[envGHToken]; got != "" { + t.Fatalf("GH_TOKEN = %q, want no ambient gh credential", got) + } } // mockSecretStore implements secrets.SecretStore for testing diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 994b467eea..33e1780f70 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -50,9 +50,8 @@ func (e *Executor) resolveTaskSessionMCPMode(ctx context.Context, taskID string, } // isContainerizedExecutor returns true for executor types that run agents in -// containers or remote sandboxes (Docker variants + Sprites). These executors -// need GitHub token injection for git operations since they don't have access -// to the host's git credentials, and they're the same set that needs the +// containers or remote sandboxes (Docker variants + Sprites). These are the +// same executors that need explicitly configured remote credentials and the // kandev-managed feature branch propagated through env metadata. func isContainerizedExecutor(executorType string) bool { switch models.ExecutorType(executorType) { @@ -64,10 +63,9 @@ func isContainerizedExecutor(executorType string) bool { } // executorNeedsResolvedCredentials reports whether an executor runs the agent -// off the control-plane host and therefore needs credentials resolved into -// req.Env (rather than inherited from the kandev process environment). This is -// every containerized executor plus SSH, whose remote agentctl only receives -// the credential keys we forward in req.Env. +// off the control-plane host and therefore needs explicitly selected profile +// credentials resolved into req.Env. This is every containerized executor plus +// SSH, whose remote agentctl only receives the keys we forward in req.Env. func executorNeedsResolvedCredentials(executorType string) bool { return isContainerizedExecutor(executorType) || models.ExecutorType(executorType) == models.ExecutorTypeSSH @@ -1123,12 +1121,9 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s } } - // For remote executors (containerized *and* SSH), resolve credentials into - // req.Env in this order: - // 1. Profile remote_auth_secrets (e.g., gh_cli_env method with secret) - // 2. Profile remote_credentials with gh_cli_token (extract from local gh CLI) - // 3. Global GITHUB_TOKEN secret (fallback) - // 4. Auto-extract from local gh CLI (final fallback) + // For remote executors (containerized *and* SSH), resolve only explicitly + // selected profile auth secrets. Workspace GitHub automation is configured + // below through a scoped renewable broker lease. // SSH is included so env-authenticated agents (e.g. claude-acp reading // CLAUDE_CODE_OAUTH_TOKEN) and remote git get their credentials from the // configured profile/secret store rather than from a blanket forward of the @@ -1143,6 +1138,9 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s if err != nil { return nil, execConfig, err } + if err := e.configureGitHubCredentialBrokerForRepositories(ctx, req, allRepos); err != nil { + return nil, execConfig, err + } // Multi-repo: when more than one repository is associated with the task, // populate req.Repositories so the lifecycle preparer creates one worktree @@ -1188,11 +1186,9 @@ func mergeEnv(req *LaunchAgentRequest, env map[string]string) { } } -// applyContainerCredentials resolves and injects credentials for containerized executors. +// applyContainerCredentials resolves explicit profile credentials for remote executors. func (e *Executor) applyContainerCredentials(ctx context.Context, req *LaunchAgentRequest, metadata map[string]interface{}) { e.resolveRemoteCredentials(ctx, req, metadata) - e.injectGitHubToken(ctx, req) // Fallback to global secret - e.injectGitHubTokenFromCLI(ctx, req) // Final fallback to local gh CLI } // buildRepoSpecs converts resolved repoInfos into per-repo launch specs for diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index 4fd1c22c86..c2e29f97d8 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -171,7 +171,8 @@ func (e *Executor) ensureRepoLocalPath(ctx context.Context, repo *models.Reposit if repo.SourceType == sourceTypeLocal || repo.ProviderOwner == "" || repo.ProviderName == "" { return nil } - if repo.LocalPath != "" && isLocalGitRepo(repo.LocalPath) { + if repo.LocalPath != "" && isLocalGitRepo(repo.LocalPath) && + (e.repoCloner == nil || !e.repoCloner.ShouldRecloneForWorkspace(repo.WorkspaceID, repo.LocalPath)) { return nil } localPath, cloneErr := e.ensureRepoCloned(ctx, repo) diff --git a/apps/backend/internal/orchestrator/executor/executor_resume_clone_default_branch_test.go b/apps/backend/internal/orchestrator/executor/executor_resume_clone_default_branch_test.go index b349737779..f08d8407d6 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume_clone_default_branch_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume_clone_default_branch_test.go @@ -391,12 +391,14 @@ func TestApplyResumeRepoConfig_SelfHealsStaleProviderPath(t *testing.T) { // fakeRepoCloner returns a fixed local path for any clone request. type fakeRepoCloner struct{ returnPath string } -func (f *fakeRepoCloner) EnsureClonedForProvider( - _ context.Context, _, _, _, _, _, _, _ string, +func (f *fakeRepoCloner) EnsureWorkspaceClonedForProvider( + _ context.Context, _, _, _, _, _, _, _, _ string, ) (string, error) { return f.returnPath, nil } +func (f *fakeRepoCloner) ShouldRecloneForWorkspace(_, _ string) bool { return false } + func (f *fakeRepoCloner) BuildCloneURLWithHost(_, _, owner, name string) (string, error) { return "https://github.com/" + owner + "/" + name + ".git", nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_resume_test.go b/apps/backend/internal/orchestrator/executor/executor_resume_test.go index 8a1c6e2e85..952648af9f 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume_test.go @@ -52,9 +52,10 @@ func TestResumeSession_RejectsArchivedTask(t *testing.T) { func setupLiveResumeTestFixture(repo *mockRepository) { now := time.Now().UTC() repo.tasks["task-1"] = &models.Task{ - ID: "task-1", - CreatedAt: now, - UpdatedAt: now, + ID: "task-1", + WorkspaceID: "workspace-1", + CreatedAt: now, + UpdatedAt: now, } repo.sessions["sess-1"] = &models.TaskSession{ ID: "sess-1", @@ -391,6 +392,9 @@ func TestResumeSession_PropagatesTaskEnvironmentID(t *testing.T) { t.Errorf("TaskEnvironmentID = %q, want %q — without this the lifecycle execution is indexed under empty env_id and GetByTaskEnvironmentID never finds it", capturedReq.TaskEnvironmentID, "env-1") } + if capturedReq.WorkspaceID != "workspace-1" { + t.Errorf("WorkspaceID = %q, want workspace-1 for resumed credential resolution", capturedReq.WorkspaceID) + } } // TestResumeSession_TerminalStateSkipsLivenessProbeOnFallback covers the diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index ae8e5dd220..f6cba060a2 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -2344,24 +2344,32 @@ func TestRepositoryCloneURL(t *testing.T) { type recordingAuthenticatedCloner struct { normalCalls int authCalls int + workspaceID string + provider string password string } -func (c *recordingAuthenticatedCloner) EnsureClonedForProvider( - _ context.Context, _, _, _, _, _, _, _ string, +func (c *recordingAuthenticatedCloner) EnsureWorkspaceClonedForProvider( + _ context.Context, workspaceID, _, provider, _, _, _, _, _ string, ) (string, error) { c.normalCalls++ + c.workspaceID = workspaceID + c.provider = provider return "/repos/normal", nil } +func (c *recordingAuthenticatedCloner) ShouldRecloneForWorkspace(_, _ string) bool { return false } + func (c *recordingAuthenticatedCloner) BuildCloneURLWithHost(_, _, _, _ string) (string, error) { return "", nil } -func (c *recordingAuthenticatedCloner) EnsureClonedWithBasicAuth( - _ context.Context, _, _, _, _, password string, +func (c *recordingAuthenticatedCloner) EnsureWorkspaceClonedWithBasicAuth( + _ context.Context, workspaceID, provider, _, _, _, _, _, password string, ) (string, error) { c.authCalls++ + c.workspaceID = workspaceID + c.provider = provider c.password = password return "/repos/azure", nil } @@ -2384,11 +2392,14 @@ func TestEnsureClonedWithWorkspaceAuth(t *testing.T) { if err != nil { t.Fatal(err) } - if path != "/repos/azure" || cloner.authCalls != 1 || cloner.password != "workspace-pat" { + if path != "/repos/azure" || cloner.authCalls != 1 || cloner.workspaceID != "workspace-1" || + cloner.provider != "azure_devops" || cloner.password != "workspace-pat" { t.Fatalf("authenticated clone was not used with workspace credential: %+v", cloner) } - github := &models.Repository{Provider: "github", ProviderOwner: "acme", ProviderName: "api"} + github := &models.Repository{ + WorkspaceID: "workspace-2", Provider: "github", ProviderOwner: "acme", ProviderName: "api", + } if _, err := exec.ensureClonedWithWorkspaceAuth(context.Background(), github, "https://github.com/acme/api.git"); err != nil { t.Fatal(err) } @@ -2399,6 +2410,9 @@ func TestEnsureClonedWithWorkspaceAuth(t *testing.T) { if cloner.normalCalls != 2 || cloner.authCalls != 1 { t.Fatalf("non-Azure-HTTPS providers must use ordinary cloning: %+v", cloner) } + if cloner.workspaceID != "workspace-1" || cloner.provider != "azure_devops" { + t.Fatalf("ordinary clone did not preserve workspace/provider isolation: %+v", cloner) + } } // TestRepositoryCloneURL_LocalPathFallback covers the second code path @@ -2668,6 +2682,12 @@ func TestLaunchPreparedSession_SerialisesConcurrentLaunches(t *testing.T) { var launchCount int64 entered := make(chan struct{}, 2) gate := make(chan struct{}) + startProcessGate := make(chan struct{}) + var releaseStartProcess sync.Once + releaseStartProcessGate := func() { + releaseStartProcess.Do(func() { close(startProcessGate) }) + } + t.Cleanup(releaseStartProcessGate) agentManager := &mockAgentManager{ launchAgentFunc: func(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) { atomic.AddInt64(&launchCount, 1) @@ -2691,6 +2711,13 @@ func TestLaunchPreparedSession_SerialisesConcurrentLaunches(t *testing.T) { getExecutionIDForSessionFunc: func(ctx context.Context, sessionID string) (string, error) { return "exec-race", nil }, + // The repository mock returns shared pointers, unlike the production + // database store. Hold both async process-start callbacks until the + // serialized launch calls finish mutating their session snapshots. + startAgentProcessFunc: func(_ context.Context, _ string) error { + <-startProcessGate + return nil + }, } executor := newTestExecutor(t, agentManager, repo) task := &v1.Task{ID: "task-race", WorkspaceID: "ws-1"} @@ -2730,6 +2757,9 @@ func TestLaunchPreparedSession_SerialisesConcurrentLaunches(t *testing.T) { } close(gate) wg.Wait() + releaseStartProcessGate() + waitForUpdateTaskStateIfNotArchivedCall(t, repo) + waitForUpdateTaskStateIfNotArchivedCall(t, repo) // First call ran LaunchAgent; second call took the fast path so total // stays at 1. Both return non-error (the second is a no-op start). diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 128d20337a..d52e3adf85 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -696,6 +696,12 @@ func (s *Service) EnsureRepositoryCloned(ctx context.Context, repository *models return s.executor.EnsureRepositoryCloned(ctx, repository) } +// SetGitHubCredentialBroker configures renewable workspace GitHub credentials +// for executor git and gh operations. +func (s *Service) SetGitHubCredentialBroker(issuer executor.GitHubCredentialLeaseIssuer, endpoint string) { + s.executor.SetGitHubCredentialBroker(issuer, endpoint) +} + // SetTurnService sets the turn service for tracking conversation turns. // // A "turn" represents a single conversation round-trip: user prompt → agent response. diff --git a/apps/backend/internal/repoclone/clone.go b/apps/backend/internal/repoclone/clone.go index 1fa839ac5d..f391638f9a 100644 --- a/apps/backend/internal/repoclone/clone.go +++ b/apps/backend/internal/repoclone/clone.go @@ -19,14 +19,20 @@ import ( "github.com/kandev/kandev/internal/common/subproc" ) -// ghCredentialHelper is the git credential helper command that delegates to gh CLI. const ( - ghCredentialHelper = "!gh auth git-credential" - gitNoTags = "--no-tags" - protocolHTTP = "http" - providerCloneDir = "_providers" + gitNoTags = "--no-tags" + githubProvider = "github" + gitlabProvider = "gitlab" + protocolHTTP = "http" + gitHubCredentialEnv = "KANDEV_REPOCLONE_GITHUB_TOKEN" + gitHubCredentialUserEnv = "KANDEV_REPOCLONE_GITHUB_USERNAME" + gitCredentialHelper = `!f() { if [ "$1" = get ]; then printf '%s\n' "username=$KANDEV_REPOCLONE_GITHUB_USERNAME" "password=$KANDEV_REPOCLONE_GITHUB_TOKEN"; fi; }; f` + managedWorkspacesDir = "workspaces" + providerCloneDir = "_providers" ) +var ErrWorkspaceCredentialUnavailable = errors.New("workspace Git credential is unavailable") + // Config holds configuration for the repository cloner. type Config struct { // BasePath is the base directory for cloned repos. @@ -37,16 +43,23 @@ type Config struct { // Cloner handles git clone and fetch operations. type Cloner struct { - config Config - protocol string - logger *logger.Logger - // repoMus is a map of per-repo path → *sync.Mutex to prevent concurrent - // clone or fetch operations on the same repository directory. - repoMus sync.Map + config Config + protocol string + logger *logger.Logger + credentials GitCredentialProvider + repoMus sync.Map } -// NewCloner creates a new Cloner with the given config, git protocol, and data directory. -// If cfg.BasePath is empty, it defaults to dataDir+"/repos". +// GitCredentialProvider resolves the workspace automation identity selected +// for Git transport. Implementations must never return a personal credential. +type GitCredentialProvider interface { + ResolveGitCredential( + ctx context.Context, + workspaceID, provider, owner, name string, + ) (username, password string, err error) +} + +// NewCloner creates a new Cloner with the given configuration. func NewCloner(cfg Config, protocol string, dataDir string, log *logger.Logger) *Cloner { if cfg.BasePath == "" && dataDir != "" { cfg.BasePath = filepath.Join(dataDir, "repos") @@ -54,7 +67,11 @@ func NewCloner(cfg Config, protocol string, dataDir string, log *logger.Logger) return &Cloner{config: cfg, protocol: protocol, logger: log} } -// repoMu returns (or lazily creates) the mutex for a repository path. +// SetGitCredentialProvider configures workspace-scoped Git transport auth. +func (c *Cloner) SetGitCredentialProvider(provider GitCredentialProvider) { + c.credentials = provider +} + func (c *Cloner) repoMu(path string) *sync.Mutex { mu, _ := c.repoMus.LoadOrStore(path, &sync.Mutex{}) return mu.(*sync.Mutex) //nolint:forcetypeassert // LoadOrStore always stores *sync.Mutex @@ -73,53 +90,96 @@ func (c *Cloner) ExpandedBasePath() (string, error) { return path, nil } -// BuildCloneURL constructs a protocol-aware clone URL for the given provider/owner/name. -// This ensures the clone URL matches the user's configured git protocol (SSH vs HTTPS). +// BuildCloneURL constructs a protocol-aware clone URL for a provider repository. func (c *Cloner) BuildCloneURL(provider, owner, name string) (string, error) { return CloneURL(provider, owner, name, c.protocol) } -// BuildCloneURLWithHost constructs a clone URL using a persisted provider -// origin. This is required for self-managed GitLab repositories. +// BuildCloneURLWithHost constructs a clone URL using a persisted provider origin. func (c *Cloner) BuildCloneURLWithHost(provider, host, owner, name string) (string, error) { return CloneURLWithHost(provider, host, owner, name, c.protocol) } -// RepoPath returns the full local path for a repository. +// RepoPath returns the legacy owner/name clone path. func (c *Cloner) RepoPath(owner, name string) (string, error) { basePath, err := c.ExpandedBasePath() if err != nil { return "", err } - targetPath := filepath.Join(basePath, owner, name) - relativePath, err := filepath.Rel(basePath, targetPath) - if err != nil { - return "", fmt.Errorf("resolve repository path: %w", err) + if err := validateOwnerPath(owner); err != nil { + return "", err } - if relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) || filepath.IsAbs(relativePath) { - return "", fmt.Errorf("repository path %q escapes clone base", targetPath) + if err := validatePathSegment(name); err != nil { + return "", err } - return targetPath, nil + return filepath.Join(basePath, filepath.FromSlash(owner), name), nil } -// ProviderRepoPath returns a clone path isolated by provider and provider host. -// Repositories can share owner/name across GitHub, GitLab.com, and self-managed -// GitLab instances, so owner/name alone is not a stable clone identity. +// ProviderRepoPath returns the legacy provider/origin clone path. func (c *Cloner) ProviderRepoPath(provider, providerHost, owner, name string) (string, error) { basePath, err := c.ExpandedBasePath() if err != nil { return "", err } + if err := validateOwnerPath(owner); err != nil { + return "", err + } + if err := validatePathSegment(name); err != nil { + return "", err + } return filepath.Join( - basePath, - providerCloneDir, - clonePathSegment(provider), - providerHostPathSegment(providerHost), - owner, - name, + basePath, providerCloneDir, clonePathSegment(provider), + providerHostPathSegment(providerHost), filepath.FromSlash(owner), name, ), nil } +// WorkspaceRepoPath returns the isolated path for a default-origin provider repository. +func (c *Cloner) WorkspaceRepoPath(workspaceID, provider, owner, name string) (string, error) { + return c.WorkspaceProviderRepoPath(workspaceID, provider, "", owner, name) +} + +// WorkspaceProviderRepoPath isolates managed clones by workspace, provider, +// and non-default provider origin. +func (c *Cloner) WorkspaceProviderRepoPath( + workspaceID, provider, providerHost, owner, name string, +) (string, error) { + basePath, err := c.ExpandedBasePath() + if err != nil { + return "", err + } + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + provider = githubProvider + } + for _, segment := range []string{workspaceID, provider, name} { + if err := validatePathSegment(segment); err != nil { + return "", err + } + } + if err := validateOwnerPath(owner); err != nil { + return "", err + } + parts := []string{basePath, managedWorkspacesDir, workspaceID, provider} + if host := nonDefaultProviderHostSegment(provider, providerHost); host != "" { + parts = append(parts, host) + } + parts = append(parts, filepath.FromSlash(owner), name) + return filepath.Join(parts...), nil +} + +func nonDefaultProviderHostSegment(provider, providerHost string) string { + parsedHost := strings.TrimSpace(providerHost) + if parsed, err := url.Parse(parsedHost); err == nil && parsed.Host != "" { + parsedHost = parsed.Host + } + parsedHost = strings.ToLower(strings.TrimSpace(parsedHost)) + if parsedHost == "" || provider == githubProvider && parsedHost == "github.com" || + provider == gitlabProvider && parsedHost == "gitlab.com" { + return "" + } + return clonePathSegment(parsedHost) +} + func providerHostPathSegment(providerHost string) string { host := strings.TrimSpace(providerHost) if parsed, err := url.Parse(host); err == nil && parsed.Host != "" { @@ -150,132 +210,168 @@ func clonePathSegment(value string) string { return segment } -// EnsureCloned clones the repository if it doesn't exist locally, or fetches if it does. -// The cloneURL is the full git URL (HTTPS or SSH) to clone from. -// Returns the local filesystem path to the repository. -// Concurrent calls for the same repository are serialised to prevent double-clone races. +func validatePathSegment(segment string) error { + if segment == "" || segment == "." || segment == ".." || filepath.Clean(segment) != segment || + strings.ContainsAny(segment, `/\`) { + return fmt.Errorf("invalid managed clone path segment %q", segment) + } + return nil +} + +func validateOwnerPath(owner string) error { + if strings.Contains(owner, `\`) { + return fmt.Errorf("invalid managed clone owner path %q", owner) + } + for _, segment := range strings.Split(owner, "/") { + if err := validatePathSegment(segment); err != nil { + return fmt.Errorf("invalid managed clone owner path %q: %w", owner, err) + } + } + return nil +} + +// ShouldRecloneForWorkspace reports whether a managed path is not isolated to the workspace. +func (c *Cloner) ShouldRecloneForWorkspace(workspaceID, path string) bool { + basePath, err := c.ExpandedBasePath() + if err != nil || path == "" { + return false + } + managed, err := pathWithin(basePath, path) + if err != nil || !managed { + return false + } + if err := validatePathSegment(workspaceID); err != nil { + return true + } + workspaceRoot := filepath.Join(basePath, managedWorkspacesDir, workspaceID) + isolated, isolatedErr := pathWithin(workspaceRoot, path) + return isolatedErr != nil || !isolated +} + +func pathWithin(root, path string) (bool, error) { + absRoot, err := filepath.Abs(root) + if err != nil { + return false, err + } + absPath, err := filepath.Abs(path) + if err != nil { + return false, err + } + rel, err := filepath.Rel(absRoot, absPath) + if err != nil { + return false, err + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)), nil +} + +// EnsureCloned retains the legacy public-clone behavior for non-workspace callers. func (c *Cloner) EnsureCloned(ctx context.Context, cloneURL, owner, name string) (string, error) { return c.EnsureClonedWithAuth(ctx, cloneURL, owner, name, "", "") } -// EnsureClonedWithAuth scopes an HTTPS credential to one exact provider -// origin. The token is passed only through the child process environment and -// is never embedded in the clone URL or persisted in .git/config. +// EnsureClonedWithAuth scopes an HTTPS credential to one exact provider origin. func (c *Cloner) EnsureClonedWithAuth( - ctx context.Context, - cloneURL, owner, name, credentialOrigin, token string, + ctx context.Context, cloneURL, owner, name, credentialOrigin, token string, ) (string, error) { targetPath, err := c.RepoPath(owner, name) if err != nil { return "", err } - return c.ensureClonedAtPath(ctx, cloneURL, targetPath, owner, name, credentialOrigin, token) + auth, err := credentialAuth(cloneURL, credentialOrigin, token) + if err != nil { + return "", err + } + return c.ensureClonedAtPath(ctx, cloneURL, targetPath, auth) } -// EnsureClonedForProvider clones or fetches a provider-backed repository using -// a path keyed by both provider and host. +// EnsureClonedForProvider retains the legacy provider/origin clone layout. func (c *Cloner) EnsureClonedForProvider( - ctx context.Context, - cloneURL, provider, providerHost, owner, name, credentialOrigin, token string, + ctx context.Context, cloneURL, provider, providerHost, owner, name, credentialOrigin, token string, ) (string, error) { targetPath, err := c.ProviderRepoPath(provider, providerHost, owner, name) if err != nil { return "", err } - return c.ensureClonedAtPath(ctx, cloneURL, targetPath, owner, name, credentialOrigin, token) -} - -func (c *Cloner) ensureClonedAtPath( - ctx context.Context, - cloneURL, targetPath, owner, name, credentialOrigin, token string, -) (string, error) { - - mu := c.repoMu(targetPath) - mu.Lock() - defer mu.Unlock() - - gitDir := filepath.Join(targetPath, ".git") - if info, statErr := os.Stat(gitDir); statErr == nil && info.IsDir() { - c.fetch(ctx, targetPath, cloneURL, credentialOrigin, token) - return targetPath, nil - } - - return targetPath, c.clone(ctx, cloneURL, targetPath, owner, name, credentialOrigin, token) -} - -// EnsureClonedWithBasicAuth clones or fetches using an ephemeral HTTP -// Authorization header. The credential is carried only in the Git child -// process environment, never in the URL, command arguments, or logs. -func (c *Cloner) EnsureClonedWithBasicAuth( - ctx context.Context, cloneURL, owner, name, username, password string, -) (string, error) { - targetPath, err := c.RepoPath(owner, name) + auth, err := credentialAuth(cloneURL, credentialOrigin, token) if err != nil { return "", err } - mu := c.repoMu(targetPath) - mu.Lock() - defer mu.Unlock() - header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) - gitDir := filepath.Join(targetPath, ".git") - if info, statErr := os.Stat(gitDir); statErr == nil && info.IsDir() { - cmd := c.gitCmdWithHTTPHeader(ctx, header, "-C", targetPath, "fetch", "--all", "--prune", "--force", gitNoTags) - if out, fetchErr := subproc.RunGitCombinedOutput(ctx, cmd); fetchErr != nil { - c.logger.Warn("authenticated git fetch failed", zap.String("path", targetPath), zap.String("output", string(out)), zap.Error(fetchErr)) - } - return targetPath, nil - } - if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { - return "", fmt.Errorf("create parent directory: %w", err) - } - c.logger.Info("cloning authenticated repository", zap.String("url", cloneURL), zap.String("target", targetPath)) - cmd := c.gitCmdWithHTTPHeader(ctx, header, "clone", "--filter=blob:none", gitNoTags, cloneURL, targetPath) - if out, cloneErr := subproc.RunGitCombinedOutput(ctx, cmd); cloneErr != nil { - return "", fmt.Errorf("git clone failed: %s: %w", string(out), cloneErr) - } - return targetPath, nil + return c.ensureClonedAtPath(ctx, cloneURL, targetPath, auth) } -func (c *Cloner) gitCmdWithHTTPHeader(ctx context.Context, header string, args ...string) *exec.Cmd { - cmd := exec.CommandContext(ctx, "git", args...) - cmd.Env = append(os.Environ(), - "GIT_TERMINAL_PROMPT=0", - "GIT_CONFIG_COUNT=1", - "GIT_CONFIG_KEY_0=http.extraHeader", - "GIT_CONFIG_VALUE_0="+header, +// EnsureWorkspaceCloned clones a repository into its workspace-isolated path. +func (c *Cloner) EnsureWorkspaceCloned( + ctx context.Context, workspaceID, provider, cloneURL, owner, name string, +) (string, error) { + return c.EnsureWorkspaceClonedForProvider( + ctx, workspaceID, cloneURL, provider, "", owner, name, "", "", ) - return cmd } -// gitCmd creates a git command with non-interactive environment settings. -// When the configured protocol is HTTPS, it adds gh CLI as the credential -// helper so that git can authenticate using the user's gh auth token. -func (c *Cloner) gitCmd( - ctx context.Context, - cloneURL, credentialOrigin, token string, - args ...string, -) (*exec.Cmd, error) { - cmd := exec.CommandContext(ctx, "git", args...) - env := os.Environ() - env = append(env, "GIT_TERMINAL_PROMPT=0") - credentialEnv, err := gitLabCredentialEnv(cloneURL, credentialOrigin, token) +// EnsureWorkspaceClonedForProvider combines workspace isolation with exact +// provider-origin credential scoping. +func (c *Cloner) EnsureWorkspaceClonedForProvider( + ctx context.Context, workspaceID, cloneURL, provider, providerHost, + owner, name, credentialOrigin, token string, +) (string, error) { + targetPath, err := c.WorkspaceProviderRepoPath(workspaceID, provider, providerHost, owner, name) if err != nil { - return nil, err + return "", err } - env = append(env, credentialEnv...) - if token == "" && c.protocol == ProtocolHTTPS { - env = append(env, - "GIT_CONFIG_COUNT=1", - "GIT_CONFIG_KEY_0=credential.helper", - "GIT_CONFIG_VALUE_0="+ghCredentialHelper, - ) + cloneURL, auth, err := c.workspaceCloneAuth( + ctx, workspaceID, provider, cloneURL, owner, name, credentialOrigin, token, + ) + if err != nil { + return "", err } - cmd.Env = env - return cmd, nil + return c.ensureClonedAtPath(ctx, cloneURL, targetPath, auth) } -func gitLabCredentialEnv(cloneURL, credentialOrigin, token string) ([]string, error) { - if token == "" { +type cloneAuth struct { + origin string + username string + password string +} + +func (c *Cloner) workspaceCloneAuth( + ctx context.Context, workspaceID, provider, cloneURL, owner, name, credentialOrigin, token string, +) (string, *cloneAuth, error) { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + provider = githubProvider + } + if provider != githubProvider { + auth, err := credentialAuth(cloneURL, credentialOrigin, token) + return cloneURL, auth, err + } + httpsURL, err := CloneURL(provider, owner, name, ProtocolHTTPS) + if err != nil { + return "", nil, err + } + if c.credentials == nil { + return "", nil, ErrWorkspaceCredentialUnavailable + } + username, password, err := c.credentials.ResolveGitCredential(ctx, workspaceID, provider, owner, name) + if err != nil { + return "", nil, fmt.Errorf("resolve workspace Git credential: %w", err) + } + if strings.TrimSpace(password) == "" { + return "", nil, ErrWorkspaceCredentialUnavailable + } + parsed, err := url.Parse(httpsURL) + if err != nil || parsed.Host == "" { + return "", nil, fmt.Errorf("parse managed clone URL %q: host is required", cloneURL) + } + if username == "" { + username = "x-access-token" + } + return httpsURL, &cloneAuth{ + origin: parsed.Scheme + "://" + parsed.Host, username: username, password: password, + }, nil +} + +func credentialAuth(cloneURL, credentialOrigin, token string) (*cloneAuth, error) { + if strings.TrimSpace(token) == "" { return nil, nil } if !isHTTPCloneURL(cloneURL) { @@ -285,12 +381,171 @@ func gitLabCredentialEnv(cloneURL, credentialOrigin, token string) ([]string, er if err != nil { return nil, err } - return []string{ - "KANDEV_GIT_CREDENTIAL_TOKEN=" + token, - "GIT_CONFIG_COUNT=1", - "GIT_CONFIG_KEY_0=credential." + origin + ".helper", - `GIT_CONFIG_VALUE_0=!f() { echo "username=oauth2"; echo "password=$KANDEV_GIT_CREDENTIAL_TOKEN"; }; f`, - }, nil + return &cloneAuth{origin: origin, username: "oauth2", password: token}, nil +} + +func (c *Cloner) ensureClonedAtPath( + ctx context.Context, cloneURL, targetPath string, auth *cloneAuth, +) (string, error) { + mu := c.repoMu(targetPath) + mu.Lock() + defer mu.Unlock() + + gitDir := filepath.Join(targetPath, ".git") + if info, statErr := os.Stat(gitDir); statErr == nil && info.IsDir() { + c.fetch(ctx, targetPath, auth) + return targetPath, nil + } + return targetPath, c.clone(ctx, cloneURL, targetPath, auth) +} + +// EnsureClonedWithBasicAuth retains the legacy basic-auth clone path. +func (c *Cloner) EnsureClonedWithBasicAuth( + ctx context.Context, cloneURL, owner, name, username, password string, +) (string, error) { + targetPath, err := c.RepoPath(owner, name) + if err != nil { + return "", err + } + return c.ensureClonedWithBasicAuth(ctx, targetPath, cloneURL, username, password) +} + +// EnsureWorkspaceClonedWithBasicAuth uses an ephemeral Authorization header +// and stores the clone under the workspace/provider/origin path. +func (c *Cloner) EnsureWorkspaceClonedWithBasicAuth( + ctx context.Context, workspaceID, provider, providerHost, + cloneURL, owner, name, username, password string, +) (string, error) { + targetPath, err := c.WorkspaceProviderRepoPath(workspaceID, provider, providerHost, owner, name) + if err != nil { + return "", err + } + return c.ensureClonedWithBasicAuth(ctx, targetPath, cloneURL, username, password) +} + +func (c *Cloner) ensureClonedWithBasicAuth( + ctx context.Context, targetPath, cloneURL, username, password string, +) (string, error) { + mu := c.repoMu(targetPath) + mu.Lock() + defer mu.Unlock() + header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) + gitDir := filepath.Join(targetPath, ".git") + if info, statErr := os.Stat(gitDir); statErr == nil && info.IsDir() { + c.fetchWithHTTPHeader(ctx, targetPath, cloneURL, header) + return targetPath, nil + } + return targetPath, c.cloneWithHTTPHeader(ctx, cloneURL, targetPath, header) +} + +func (c *Cloner) fetch(ctx context.Context, repoPath string, auth *cloneAuth) { + c.logger.Debug("repository already cloned, fetching", zap.String("path", repoPath)) + cmd := exec.CommandContext( + ctx, "git", "-C", repoPath, "fetch", "--all", "--prune", "--force", gitNoTags, + ) + configureGitCommand(cmd, auth) + if out, err := subproc.RunGitCombinedOutput(ctx, cmd); err != nil { + c.logger.Warn("git fetch failed (non-fatal)", + zap.String("path", repoPath), zap.String("output", redactCloneOutput(string(out), authToken(auth))), zap.Error(err)) + } +} + +func (c *Cloner) clone(ctx context.Context, cloneURL, targetPath string, auth *cloneAuth) error { + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return fmt.Errorf("create parent directory: %w", err) + } + c.logger.Info("cloning repository", zap.String("url", redactCloneURL(cloneURL)), zap.String("target", targetPath)) + cmd := exec.CommandContext( + ctx, "git", "clone", "--filter=blob:none", gitNoTags, "--", cloneURL, targetPath, + ) + configureGitCommand(cmd, auth) + if out, err := subproc.RunGitCombinedOutput(ctx, cmd); err != nil { + return fmt.Errorf("git clone failed: %s: %w", redactCloneOutput(string(out), authToken(auth)), err) + } + return nil +} + +func (c *Cloner) fetchWithHTTPHeader(ctx context.Context, repoPath, authURL, header string) { + c.logger.Debug("repository already cloned, fetching", zap.String("path", repoPath)) + cmd := exec.CommandContext( + ctx, "git", "-C", repoPath, "fetch", "--all", "--prune", "--force", gitNoTags, + ) + configureHTTPHeaderCommand(cmd, authURL, header) + if out, err := subproc.RunGitCombinedOutput(ctx, cmd); err != nil { + c.logger.Warn("authenticated git fetch failed (non-fatal)", + zap.String("path", repoPath), zap.String("output", string(out)), zap.Error(err)) + } +} + +func (c *Cloner) cloneWithHTTPHeader(ctx context.Context, cloneURL, targetPath, header string) error { + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return fmt.Errorf("create parent directory: %w", err) + } + c.logger.Info("cloning authenticated repository", zap.String("url", redactCloneURL(cloneURL)), zap.String("target", targetPath)) + cmd := exec.CommandContext( + ctx, "git", "clone", "--filter=blob:none", gitNoTags, "--", cloneURL, targetPath, + ) + configureHTTPHeaderCommand(cmd, cloneURL, header) + if out, err := subproc.RunGitCombinedOutput(ctx, cmd); err != nil { + return fmt.Errorf("git clone failed: %s: %w", string(out), err) + } + return nil +} + +func configureGitCommand(cmd *exec.Cmd, auth *cloneAuth) { + env := cleanGitEnvironment() + if auth == nil { + env = append(env, + "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=", + ) + cmd.Env = env + return + } + env = append(env, + "GIT_CONFIG_COUNT=3", + "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=", + "GIT_CONFIG_KEY_1=credential."+auth.origin+".helper", "GIT_CONFIG_VALUE_1="+gitCredentialHelper, + "GIT_CONFIG_KEY_2=credential.useHttpPath", "GIT_CONFIG_VALUE_2=true", + gitHubCredentialEnv+"="+auth.password, + gitHubCredentialUserEnv+"="+auth.username, + ) + cmd.Env = env +} + +func configureHTTPHeaderCommand(cmd *exec.Cmd, authURL, header string) { + cmd.Env = append(cleanGitEnvironment(), + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=", + "GIT_CONFIG_KEY_1=http."+authURL+".extraHeader", "GIT_CONFIG_VALUE_1="+header, + ) +} + +func cleanGitEnvironment() []string { + env := withoutEnv(os.Environ(), + "GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", gitHubCredentialEnv, gitHubCredentialUserEnv, + "GIT_ASKPASS", "SSH_ASKPASS", "GIT_SSH", "GIT_SSH_COMMAND", + "GIT_CONFIG", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_NOSYSTEM", + ) + return append(env, + "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+os.DevNull, + ) +} + +func withoutEnv(env []string, keys ...string) []string { + blocked := make(map[string]struct{}, len(keys)) + for _, key := range keys { + blocked[key] = struct{}{} + } + result := make([]string, 0, len(env)) + for _, entry := range env { + key, _, _ := strings.Cut(entry, "=") + _, remove := blocked[key] + if remove || strings.HasPrefix(key, "GIT_CONFIG_KEY_") || strings.HasPrefix(key, "GIT_CONFIG_VALUE_") { + continue + } + result = append(result, entry) + } + return result } func isHTTPCloneURL(cloneURL string) bool { @@ -343,53 +598,6 @@ func matchingCredentialOrigin(cloneURL, credentialOrigin string) (string, error) return credential.Scheme + "://" + credential.Host, nil } -func (c *Cloner) fetch(ctx context.Context, repoPath, cloneURL, credentialOrigin, token string) { - c.logger.Debug("repository already cloned, fetching", zap.String("path", repoPath)) - cmd, err := c.gitCmd(ctx, cloneURL, credentialOrigin, token, "-C", repoPath, "fetch", "--all", "--prune", "--force", gitNoTags) - if err != nil { - c.logger.Warn("git fetch credential host mismatch", zap.String("path", repoPath)) - return - } - if out, err := subproc.RunGitCombinedOutput(ctx, cmd); err != nil { - c.logger.Warn("git fetch failed (non-fatal)", - zap.String("path", repoPath), - zap.String("output", string(out)), - zap.Error(err)) - } -} - -func (c *Cloner) clone(ctx context.Context, cloneURL, targetPath, owner, name, credentialOrigin, token string) error { - parentDir := filepath.Dir(targetPath) - if err := os.MkdirAll(parentDir, 0o755); err != nil { - return fmt.Errorf("create parent directory: %w", err) - } - - c.logger.Info("cloning repository", - zap.String("url", redactCloneURL(cloneURL)), - zap.String("target", targetPath)) - - // Try gh repo clone first — handles auth for both SSH and HTTPS. - if owner != "" && name != "" && strings.Contains(cloneURL, "github.com") { - if err := c.ghClone(ctx, owner, name, targetPath); err == nil { - return nil - } - // Clean up any partial clone so the fallback can retry into a fresh path. - if rmErr := os.RemoveAll(targetPath); rmErr != nil && !os.IsNotExist(rmErr) { - return fmt.Errorf("cleanup failed gh clone target: %w", rmErr) - } - } - - // Fallback: git clone with credential helper. - cmd, err := c.gitCmd(ctx, cloneURL, credentialOrigin, token, "clone", "--filter=blob:none", gitNoTags, cloneURL, targetPath) - if err != nil { - return err - } - if out, err := subproc.RunGitCombinedOutput(ctx, cmd); err != nil { - return fmt.Errorf("git clone failed: %s: %w", redactCloneOutput(string(out), token), err) - } - return nil -} - func redactCloneURL(raw string) string { parsed, err := url.Parse(raw) if err != nil { @@ -406,19 +614,9 @@ func redactCloneOutput(output, token string) string { return strings.ReplaceAll(output, token, "[REDACTED]") } -// ghClone attempts to clone using gh repo clone, which handles authentication -// automatically via the user's gh CLI session. -func (c *Cloner) ghClone(ctx context.Context, owner, name, targetPath string) error { - nwo := owner + "/" + name - cmd := exec.CommandContext(ctx, "gh", "repo", "clone", nwo, targetPath, "--", "--filter=blob:none", gitNoTags) - cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") - out, err := subproc.RunGHCombinedOutput(ctx, cmd) - if err != nil { - c.logger.Debug("gh repo clone failed, falling back to git clone", - zap.String("repo", nwo), - zap.String("output", string(out)), - zap.Error(err)) - return fmt.Errorf("gh repo clone: %w", err) +func authToken(auth *cloneAuth) string { + if auth == nil { + return "" } - return nil + return auth.password } diff --git a/apps/backend/internal/repoclone/clone_auth_test.go b/apps/backend/internal/repoclone/clone_auth_test.go index e200a59e65..3d7b00b0f9 100644 --- a/apps/backend/internal/repoclone/clone_auth_test.go +++ b/apps/backend/internal/repoclone/clone_auth_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -14,24 +15,32 @@ import ( func TestGitCmdWithHTTPHeaderKeepsCredentialOutOfArguments(t *testing.T) { t.Parallel() - cloner := &Cloner{} + cloneURL := "https://dev.azure.com/acme/p/_git/r" header := "Authorization: Basic c2VjcmV0" - cmd := cloner.gitCmdWithHTTPHeader(context.Background(), header, "clone", "https://dev.azure.com/acme/p/_git/r") + cmd := exec.CommandContext(context.Background(), "git", "clone", "--", cloneURL) + configureHTTPHeaderCommand(cmd, cloneURL, header) if strings.Contains(strings.Join(cmd.Args, " "), "c2VjcmV0") { t.Fatal("credential leaked into command arguments") } - found := false + foundHeader := false + foundScope := false for _, value := range cmd.Env { - if value == "GIT_CONFIG_VALUE_0="+header { - found = true + if value == "GIT_CONFIG_VALUE_1="+header { + foundHeader = true + } + if value == "GIT_CONFIG_KEY_1=http."+cloneURL+".extraHeader" { + foundScope = true } } - if !found { + if !foundHeader { t.Fatal("authorization header was not provided through the Git child environment") } + if !foundScope { + t.Fatal("authorization header was not scoped to the authenticated repository URL") + } } -func TestEnsureClonedWithBasicAuthKeepsCredentialScopedToGitChild(t *testing.T) { +func TestEnsureWorkspaceClonedWithBasicAuthKeepsCredentialScopedToGitChild(t *testing.T) { tests := []struct { name string cancel bool @@ -43,7 +52,7 @@ func TestEnsureClonedWithBasicAuthKeepsCredentialScopedToGitChild(t *testing.T) t.Run(tc.name, func(t *testing.T) { binDir := t.TempDir() capturePath := filepath.Join(t.TempDir(), "git-env") - fakeGit := "#!/bin/sh\nprintf '%s' \"$GIT_CONFIG_VALUE_0\" > \"$CAPTURE_PATH\"\n" + + fakeGit := "#!/bin/sh\nprintf '%s\\n%s' \"$GIT_CONFIG_KEY_1\" \"$GIT_CONFIG_VALUE_1\" > \"$CAPTURE_PATH\"\n" + "if [ \"$BLOCK_GIT\" = 1 ]; then exec sleep 10; fi\nexit 1\n" if err := os.WriteFile(filepath.Join(binDir, "git"), []byte(fakeGit), 0o755); err != nil { t.Fatal(err) @@ -61,8 +70,9 @@ func TestEnsureClonedWithBasicAuthKeepsCredentialScopedToGitChild(t *testing.T) ctx, cancel = context.WithTimeout(ctx, 20*time.Millisecond) defer cancel() } - _, err := cloner.EnsureClonedWithBasicAuth( - ctx, "https://dev.azure.com/acme/p/_git/r", "p", "r", "kandev", "secret-pat", + targetPath, err := cloner.EnsureWorkspaceClonedWithBasicAuth( + ctx, "workspace-a", "azure_devops", "", "https://dev.azure.com/acme/p/_git/r", + "p", "r", "kandev", "secret-pat", ) if err == nil { t.Fatal("expected git clone error") @@ -70,6 +80,10 @@ func TestEnsureClonedWithBasicAuthKeepsCredentialScopedToGitChild(t *testing.T) if tc.cancel && ctx.Err() != context.DeadlineExceeded { t.Fatalf("expected cancelled clone context, got %v", ctx.Err()) } + wantPath := filepath.Join("workspaces", "workspace-a", "azure_devops", "p", "r") + if !strings.Contains(targetPath, wantPath) { + t.Fatalf("authenticated clone path = %q, want workspace-isolated path containing %q", targetPath, wantPath) + } captured, readErr := os.ReadFile(capturePath) if readErr != nil { t.Fatal(readErr) @@ -78,6 +92,10 @@ func TestEnsureClonedWithBasicAuthKeepsCredentialScopedToGitChild(t *testing.T) if !strings.Contains(string(captured), expectedCredential) { t.Fatal("credential was not passed to Git child") } + expectedScope := "http.https://dev.azure.com/acme/p/_git/r.extraHeader" + if !strings.Contains(string(captured), expectedScope) { + t.Fatal("credential was not scoped to the authenticated repository URL") + } if os.Getenv("GIT_CONFIG_VALUE_0") != "" { t.Fatal("credential escaped into the parent process environment") } diff --git a/apps/backend/internal/repoclone/clone_test.go b/apps/backend/internal/repoclone/clone_test.go index 1f5ce7b0f4..5c21845dbf 100644 --- a/apps/backend/internal/repoclone/clone_test.go +++ b/apps/backend/internal/repoclone/clone_test.go @@ -2,6 +2,7 @@ package repoclone import ( "context" + "errors" "os" "os/exec" "path/filepath" @@ -43,7 +44,7 @@ func TestClone_PreservesNonDefaultRemoteBranches(t *testing.T) { targetPath := filepath.Join(t.TempDir(), "clone") cloner := NewCloner(Config{}, ProtocolSSH, t.TempDir(), logger.Default()) - if err := cloner.clone(context.Background(), originPath, targetPath, "", "", "", ""); err != nil { + if err := cloner.clone(context.Background(), originPath, targetPath, nil); err != nil { t.Fatalf("clone() unexpected error: %v", err) } @@ -52,93 +53,309 @@ func TestClone_PreservesNonDefaultRemoteBranches(t *testing.T) { } } -func TestRepoPathConfinesRepositoryToCloneBase(t *testing.T) { +func TestWorkspaceRepoPathIsolatesManagedClones(t *testing.T) { + t.Parallel() + + basePath := t.TempDir() + cloner := NewCloner(Config{BasePath: basePath}, ProtocolSSH, "", logger.Default()) + first, err := cloner.WorkspaceRepoPath("workspace-a", "github", "acme", "private") + if err != nil { + t.Fatalf("WorkspaceRepoPath(workspace-a): %v", err) + } + second, err := cloner.WorkspaceRepoPath("workspace-b", "github", "acme", "private") + if err != nil { + t.Fatalf("WorkspaceRepoPath(workspace-b): %v", err) + } + if first == second { + t.Fatalf("workspace clone paths must differ, both were %q", first) + } + if !cloner.ShouldRecloneForWorkspace("workspace-b", first) { + t.Fatal("workspace-b must not reuse workspace-a's managed clone") + } + if cloner.ShouldRecloneForWorkspace("workspace-a", first) { + t.Fatal("workspace-a should reuse its own managed clone") + } + legacy := filepath.Join(basePath, "acme", "private") + if !cloner.ShouldRecloneForWorkspace("workspace-a", legacy) { + t.Fatal("legacy shared managed clone must be rematerialized") + } +} + +func TestWorkspaceProviderRepoPathSeparatesSelfManagedOrigins(t *testing.T) { t.Parallel() basePath := t.TempDir() cloner := NewCloner(Config{BasePath: basePath}, ProtocolHTTPS, "", logger.Default()) - - path, err := cloner.RepoPath("group/subgroup", "repository") + first, err := cloner.WorkspaceProviderRepoPath( + "workspace-a", "gitlab", "https://gitlab.first.internal", "acme", "api", + ) if err != nil { - t.Fatalf("RepoPath() unexpected error: %v", err) + t.Fatalf("first WorkspaceProviderRepoPath: %v", err) } - want := filepath.Join(basePath, "group", "subgroup", "repository") - if path != want { - t.Fatalf("RepoPath() = %q, want %q", path, want) + second, err := cloner.WorkspaceProviderRepoPath( + "workspace-a", "gitlab", "https://gitlab.second.internal", "acme", "api", + ) + if err != nil { + t.Fatalf("second WorkspaceProviderRepoPath: %v", err) } + defaultOrigin, err := cloner.WorkspaceProviderRepoPath( + "workspace-a", "gitlab", "https://gitlab.com", "acme", "api", + ) + if err != nil { + t.Fatalf("default WorkspaceProviderRepoPath: %v", err) + } + if first == second { + t.Fatalf("self-managed origins collided at %q", first) + } + if !strings.Contains(first, filepath.Join("gitlab", "gitlab.first.internal", "acme", "api")) { + t.Fatalf("first path did not retain normalized origin: %q", first) + } + wantDefault := filepath.Join(basePath, "workspaces", "workspace-a", "gitlab", "acme", "api") + if defaultOrigin != wantDefault { + t.Fatalf("default origin path = %q, want %q", defaultOrigin, wantDefault) + } +} - for _, test := range []struct { - name string - owner string - repo string +func TestWorkspaceRepoPathRejectsTraversal(t *testing.T) { + t.Parallel() + + cloner := NewCloner(Config{BasePath: t.TempDir()}, ProtocolHTTPS, "", logger.Default()) + for _, testCase := range []struct { + name, workspaceID, owner, repo string }{ - {name: "owner traversal", owner: "../../outside", repo: "repository"}, - {name: "repository traversal", owner: "group", repo: "../../../outside"}, + {name: "workspace", workspaceID: "../outside", owner: "acme", repo: "private"}, + {name: "owner", workspaceID: "workspace", owner: "../outside", repo: "private"}, + {name: "repo", workspaceID: "workspace", owner: "acme", repo: "../outside"}, } { - t.Run(test.name, func(t *testing.T) { - if _, pathErr := cloner.RepoPath(test.owner, test.repo); pathErr == nil { - t.Fatal("RepoPath() accepted a path outside the clone base") + t.Run(testCase.name, func(t *testing.T) { + if _, err := cloner.WorkspaceRepoPath(testCase.workspaceID, "github", testCase.owner, testCase.repo); err == nil { + t.Fatal("WorkspaceRepoPath() expected traversal error") } }) } } -func TestGitCmdBindsGitLabCredentialToExactOrigin(t *testing.T) { - cloner := NewCloner(Config{}, ProtocolHTTPS, t.TempDir(), logger.Default()) - cmd, err := cloner.gitCmd( - context.Background(), - "https://gitlab.internal/group/repo.git", - "https://gitlab.internal", - "workspace-token", - "clone", +func TestWorkspaceRepoPathSupportsNestedProviderOwner(t *testing.T) { + t.Parallel() + + basePath := t.TempDir() + cloner := NewCloner(Config{BasePath: basePath}, ProtocolHTTPS, "", logger.Default()) + path, err := cloner.WorkspaceRepoPath("workspace-a", "gitlab", "group/subgroup", "repository") + if err != nil { + t.Fatalf("WorkspaceRepoPath() unexpected error: %v", err) + } + want := filepath.Join(basePath, "workspaces", "workspace-a", "gitlab", "group", "subgroup", "repository") + if path != want { + t.Fatalf("WorkspaceRepoPath() = %q, want %q", path, want) + } +} + +func TestEnsureWorkspaceClonedUsesSelectedCredentialWithoutAmbientFallback(t *testing.T) { + root := t.TempDir() + binDir := filepath.Join(root, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin: %v", err) + } + capturePath := filepath.Join(root, "capture") + gitPath := filepath.Join(binDir, "git") + script := `#!/bin/sh +printf '%s\n' "$@" > "$KANDEV_TEST_CAPTURE.args" +printf '%s\n' "$GH_TOKEN|$GITHUB_TOKEN|$GIT_CONFIG_GLOBAL|$GIT_CONFIG_NOSYSTEM|$KANDEV_REPOCLONE_GITHUB_USERNAME|$KANDEV_REPOCLONE_GITHUB_TOKEN|$GIT_CONFIG_VALUE_1" > "$KANDEV_TEST_CAPTURE.env" +` + if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil { + t.Fatalf("write fake git: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("KANDEV_TEST_CAPTURE", capturePath) + t.Setenv("GH_TOKEN", "ambient-gh-token") + t.Setenv("GITHUB_TOKEN", "ambient-github-token") + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "credential.helper") + t.Setenv("GIT_CONFIG_VALUE_0", "!malicious-helper") + + credentials := &recordingCredentialProvider{password: "workspace-token"} + cloner := NewCloner(Config{BasePath: filepath.Join(root, "repos")}, ProtocolSSH, "", logger.Default()) + cloner.SetGitCredentialProvider(credentials) + target, err := cloner.EnsureWorkspaceCloned( + context.Background(), "workspace-a", "github", "git@github.com:acme/private.git", "acme", "private", ) if err != nil { - t.Fatalf("gitCmd: %v", err) + t.Fatalf("EnsureWorkspaceCloned(): %v", err) } + if credentials.workspaceID != "workspace-a" { + t.Fatalf("credential workspace = %q, want workspace-a", credentials.workspaceID) + } + if !strings.Contains(target, filepath.Join("workspaces", "workspace-a", "github", "acme", "private")) { + t.Fatalf("target path %q is not workspace isolated", target) + } + args := readTestFile(t, capturePath+".args") + if !strings.Contains(args, "https://github.com/acme/private.git") { + t.Fatalf("git args do not contain credential-compatible HTTPS URL: %s", args) + } + if !strings.Contains(args, "--\nhttps://github.com/acme/private.git") { + t.Fatalf("git args do not terminate clone options before the URL: %s", args) + } + if strings.Contains(args, "workspace-token") || strings.Contains(args, "ambient-") { + t.Fatalf("git args leaked credential material: %s", args) + } + env := strings.TrimSpace(readTestFile(t, capturePath+".env")) + wantParts := []string{"", "", os.DevNull, "1", "x-access-token", "workspace-token", gitCredentialHelper} + if got, want := strings.Split(env, "|"), wantParts; strings.Join(got, "\x00") != strings.Join(want, "\x00") { + t.Fatalf("git auth environment = %#v, want %#v", got, want) + } + cmd := exec.CommandContext(context.Background(), "git", "version") + configureGitCommand(cmd, &cloneAuth{ + origin: "https://github.com", username: "x-access-token", password: "workspace-token", + }) + assertUniqueGitConfigEnv(t, cmd.Env) +} + +func TestManagedGitCommandExecutesWithCompleteConfigAndNoAmbientAuth(t *testing.T) { + t.Setenv("GH_TOKEN", "ambient-gh-token") + t.Setenv("GITHUB_TOKEN", "ambient-github-token") + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "credential.helper") + t.Setenv("GIT_CONFIG_VALUE_0", "!printf 'password=ambient-token\\n'") + + cmd := exec.CommandContext(context.Background(), "git", "credential", "fill") + configureGitCommand(cmd, &cloneAuth{ + origin: "https://github.com", username: "workspace-user", password: "workspace-token", + }) + cmd.Stdin = strings.NewReader("protocol=https\nhost=github.com\npath=acme/private.git\n\n") + + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("managed git credential command failed: %v\n%s", err, out) + } + got := string(out) + if !strings.Contains(got, "username=workspace-user") || !strings.Contains(got, "password=workspace-token") { + t.Fatalf("managed credential output = %q", got) + } + if strings.Contains(got, "ambient-token") || envValue(cmd.Env, "GH_TOKEN") != "" || + envValue(cmd.Env, "GITHUB_TOKEN") != "" { + t.Fatalf("ambient authentication reached managed git: output=%q env=%v", got, cmd.Env) + } +} + +func TestEnsureWorkspaceClonedRequiresExplicitGitHubCredential(t *testing.T) { + t.Parallel() + + cloner := NewCloner(Config{BasePath: t.TempDir()}, ProtocolHTTPS, "", logger.Default()) + _, err := cloner.EnsureWorkspaceCloned( + context.Background(), "workspace-a", "github", "https://github.com/acme/private.git", "acme", "private", + ) + if !errors.Is(err, ErrWorkspaceCredentialUnavailable) { + t.Fatalf("EnsureWorkspaceCloned() error = %v, want credential unavailable", err) + } +} + +func TestWorkspaceCloneAuthPreservesNonGitHubURL(t *testing.T) { + t.Parallel() + + cloner := NewCloner(Config{BasePath: t.TempDir()}, ProtocolSSH, "", logger.Default()) + want := "git@ssh.dev.azure.com:v3/acme/Platform/api" + got, auth, err := cloner.workspaceCloneAuth( + context.Background(), "workspace-a", "azure_devops", want, "Platform", "api", "", "", + ) + if err != nil { + t.Fatalf("workspaceCloneAuth() unexpected error: %v", err) + } + if got != want || auth != nil { + t.Fatalf("workspaceCloneAuth() = (%q, %#v), want (%q, nil)", got, auth, want) + } +} + +type recordingCredentialProvider struct { + workspaceID string + password string +} + +func (p *recordingCredentialProvider) ResolveGitCredential( + _ context.Context, + workspaceID, _, _, _ string, +) (string, string, error) { + p.workspaceID = workspaceID + return "x-access-token", p.password, nil +} + +func readTestFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(content) +} + +func assertUniqueGitConfigEnv(t *testing.T, env []string) { + t.Helper() + counts := make(map[string]int) + for _, entry := range env { + key, _, _ := strings.Cut(entry, "=") + if key == "GIT_CONFIG_COUNT" || strings.HasPrefix(key, "GIT_CONFIG_KEY_") || + strings.HasPrefix(key, "GIT_CONFIG_VALUE_") { + counts[key]++ + } + } + for key, count := range counts { + if count != 1 { + t.Fatalf("%s occurs %d times in git environment", key, count) + } + } +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + return strings.TrimPrefix(entry, prefix) + } + } + return "" +} + +func TestGitCmdBindsGitLabCredentialToExactOrigin(t *testing.T) { + auth, err := credentialAuth( + "https://gitlab.internal/group/repo.git", "https://gitlab.internal", "workspace-token", + ) + if err != nil { + t.Fatalf("credentialAuth: %v", err) + } + cmd := exec.CommandContext(context.Background(), "git", "version") + configureGitCommand(cmd, auth) joined := strings.Join(cmd.Env, "\n") - if !strings.Contains(joined, "KANDEV_GIT_CREDENTIAL_TOKEN=workspace-token") || - !strings.Contains(joined, "GIT_CONFIG_KEY_0=credential.https://gitlab.internal.helper") { + if !strings.Contains(joined, gitHubCredentialEnv+"=workspace-token") || + !strings.Contains(joined, "GIT_CONFIG_KEY_1=credential.https://gitlab.internal.helper") { t.Fatalf("credential env = %s", joined) } - if _, err := cloner.gitCmd( - context.Background(), - "https://gitlab.com/group/repo.git", - "https://gitlab.internal", - "must-not-leak", - "clone", + if _, err := credentialAuth( + "https://gitlab.com/group/repo.git", "https://gitlab.internal", "must-not-leak", ); err == nil { t.Fatal("expected cross-host credential binding to fail") } } func TestGitCmdUsesSSHAuthForMatchingWorkspaceGitLabHost(t *testing.T) { - cloner := NewCloner(Config{}, ProtocolSSH, t.TempDir(), logger.Default()) for _, cloneURL := range []string{ "git@gitlab.internal:group/repo.git", "ssh://git@gitlab.internal:2222/group/repo.git", } { - cmd, err := cloner.gitCmd( - context.Background(), cloneURL, "https://gitlab.internal:8443", "workspace-token", "clone", - ) + auth, err := credentialAuth(cloneURL, "https://gitlab.internal:8443", "workspace-token") if err != nil { - t.Fatalf("gitCmd(%q): %v", cloneURL, err) + t.Fatalf("credentialAuth(%q): %v", cloneURL, err) } - joined := strings.Join(cmd.Env, "\n") - if strings.Contains(joined, "workspace-token") || strings.Contains(joined, "credential.gitlab") { - t.Fatalf("SSH clone received HTTP credentials: %s", joined) + if auth != nil { + t.Fatalf("SSH clone received HTTP credentials: %#v", auth) } } } func TestGitCmdRejectsSSHCloneForDifferentWorkspaceGitLabHost(t *testing.T) { - cloner := NewCloner(Config{}, ProtocolSSH, t.TempDir(), logger.Default()) for _, cloneURL := range []string{ "git@gitlab.other:group/repo.git", "ssh://git@gitlab.other:2222/group/repo.git", } { - if _, err := cloner.gitCmd( - context.Background(), cloneURL, "https://gitlab.internal", "must-not-leak", "clone", - ); err == nil { + if _, err := credentialAuth(cloneURL, "https://gitlab.internal", "must-not-leak"); err == nil { t.Fatalf("gitCmd accepted mismatched SSH clone %q", cloneURL) } } diff --git a/apps/backend/internal/secrets/user_visible_store.go b/apps/backend/internal/secrets/user_visible_store.go new file mode 100644 index 0000000000..006a50a36d --- /dev/null +++ b/apps/backend/internal/secrets/user_visible_store.go @@ -0,0 +1,85 @@ +package secrets + +import ( + "context" + "fmt" + "strings" +) + +const internalGitHubSecretPrefix = "github:" + +// IsInternalID reports whether a secret is owned by backend infrastructure +// and must never be listed, revealed, or selected as an agent credential. +func IsInternalID(id string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(id)), internalGitHubSecretPrefix) +} + +// UserVisibleStore restricts a SecretStore to user-managed credentials. The +// underlying store remains available to integration services for their +// deterministic internal keys. +type UserVisibleStore struct { + store SecretStore +} + +func NewUserVisibleStore(store SecretStore) SecretStore { + if store == nil { + return nil + } + return &UserVisibleStore{store: store} +} + +func (s *UserVisibleStore) Create(ctx context.Context, secret *SecretWithValue) error { + if secret != nil && IsInternalID(secret.ID) { + return internalSecretNotFound(secret.ID) + } + return s.store.Create(ctx, secret) +} + +func (s *UserVisibleStore) Get(ctx context.Context, id string) (*Secret, error) { + if IsInternalID(id) { + return nil, internalSecretNotFound(id) + } + return s.store.Get(ctx, id) +} + +func (s *UserVisibleStore) Reveal(ctx context.Context, id string) (string, error) { + if IsInternalID(id) { + return "", internalSecretNotFound(id) + } + return s.store.Reveal(ctx, id) +} + +func (s *UserVisibleStore) Update(ctx context.Context, id string, req *UpdateSecretRequest) error { + if IsInternalID(id) { + return internalSecretNotFound(id) + } + return s.store.Update(ctx, id, req) +} + +func (s *UserVisibleStore) Delete(ctx context.Context, id string) error { + if IsInternalID(id) { + return internalSecretNotFound(id) + } + return s.store.Delete(ctx, id) +} + +func (s *UserVisibleStore) List(ctx context.Context) ([]*SecretListItem, error) { + items, err := s.store.List(ctx) + if err != nil { + return nil, err + } + visible := make([]*SecretListItem, 0, len(items)) + for _, item := range items { + if item != nil && !IsInternalID(item.ID) { + visible = append(visible, item) + } + } + return visible, nil +} + +// The wrapped store is owned and closed by the repository container. +func (s *UserVisibleStore) Close() error { return nil } + +func internalSecretNotFound(id string) error { + return fmt.Errorf("%w: %s", ErrNotFound, id) +} diff --git a/apps/backend/internal/secrets/user_visible_store_test.go b/apps/backend/internal/secrets/user_visible_store_test.go new file mode 100644 index 0000000000..e2d35e6d45 --- /dev/null +++ b/apps/backend/internal/secrets/user_visible_store_test.go @@ -0,0 +1,55 @@ +package secrets + +import ( + "context" + "errors" + "testing" +) + +func TestUserVisibleStoreHidesInternalGitHubSecrets(t *testing.T) { + store := newTestSQLiteStore(t) + ctx := context.Background() + internal := &SecretWithValue{ + Secret: Secret{ID: "github:user:workspace-1:user-1:access", Name: "personal token"}, + Value: "personal-secret", + } + visible := &SecretWithValue{Secret: Secret{ID: "user-secret", Name: "user token"}, Value: "user-value"} + if err := store.Create(ctx, internal); err != nil { + t.Fatal(err) + } + if err := store.Create(ctx, visible); err != nil { + t.Fatal(err) + } + + wrapped := NewUserVisibleStore(store) + items, err := wrapped.List(ctx) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].ID != visible.ID { + t.Fatalf("visible secrets = %+v, want only %q", items, visible.ID) + } + for _, operation := range []func() error{ + func() error { _, err := wrapped.Get(ctx, internal.ID); return err }, + func() error { _, err := wrapped.Reveal(ctx, internal.ID); return err }, + func() error { return wrapped.Update(ctx, internal.ID, &UpdateSecretRequest{}) }, + func() error { return wrapped.Delete(ctx, internal.ID) }, + } { + if err := operation(); !errors.Is(err, ErrNotFound) { + t.Fatalf("internal operation error = %v, want not found", err) + } + } + if got, err := store.Reveal(ctx, internal.ID); err != nil || got != "personal-secret" { + t.Fatalf("raw internal reveal = %q, %v", got, err) + } +} + +func TestUserVisibleStoreRejectsInternalCreate(t *testing.T) { + store := newTestSQLiteStore(t) + err := NewUserVisibleStore(store).Create(context.Background(), &SecretWithValue{ + Secret: Secret{ID: "github:workspace:workspace-1:pat", Name: "PAT"}, Value: "secret", + }) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("Create() error = %v, want not found", err) + } +} diff --git a/apps/backend/internal/task/handlers/repository_handlers_test.go b/apps/backend/internal/task/handlers/repository_handlers_test.go index 0487eea3ec..73cbb9851e 100644 --- a/apps/backend/internal/task/handlers/repository_handlers_test.go +++ b/apps/backend/internal/task/handlers/repository_handlers_test.go @@ -249,12 +249,13 @@ func TestHTTPListRepositoriesIncludesRemoteURL(t *testing.T) { } type repositoryHandlerRemoteLister struct { - calls int + calls int + expectedWorkspaceID string } -func (l *repositoryHandlerRemoteLister) ListRepoBranches(_ context.Context, owner, name string) ([]service.Branch, error) { +func (l *repositoryHandlerRemoteLister) ListRepoBranches(_ context.Context, workspaceID, owner, name string) ([]service.Branch, error) { l.calls++ - if owner != "owner" || name != "repo" { + if workspaceID != l.expectedWorkspaceID || owner != "owner" || name != "repo" { return nil, errors.New("unexpected provider identity") } return []service.Branch{{Name: "main", Type: "remote"}}, nil @@ -274,7 +275,7 @@ func TestHTTPListRepositoryBranchesUsesRepositoryIdentity(t *testing.T) { }); err != nil { t.Fatalf("CreateRepository: %v", err) } - svc.SetRemoteBranchLister(&repositoryHandlerRemoteLister{}) + svc.SetRemoteBranchLister(&repositoryHandlerRemoteLister{expectedWorkspaceID: "ws-1"}) request := httptest.NewRequest(http.MethodGet, "/api/v1/repositories/provider-repo/branches", nil) response := httptest.NewRecorder() @@ -356,7 +357,7 @@ func TestHTTPListBranchesRejectsRepositoryFromAnotherWorkspace(t *testing.T) { }); err != nil { t.Fatalf("CreateRepository: %v", err) } - lister := &repositoryHandlerRemoteLister{} + lister := &repositoryHandlerRemoteLister{expectedWorkspaceID: "ws-b"} svc.SetRemoteBranchLister(lister) crossWorkspace := httptest.NewRequest( diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 7a249e55e6..1d7e212237 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -844,9 +844,10 @@ type TaskSessionWorktree struct { // SessionBranchInfo is a lightweight projection of a session with its worktree branch. // Used by the PR watch reconciler to find sessions that may need PR watches. type SessionBranchInfo struct { - SessionID string - TaskID string - Branch string + SessionID string + TaskID string + WorkspaceID string + Branch string } // TaskSession represents a persistent agent execution session for a task. diff --git a/apps/backend/internal/task/repository/sqlite/session_branch.go b/apps/backend/internal/task/repository/sqlite/session_branch.go index d1e52b0b11..68acaa64f8 100644 --- a/apps/backend/internal/task/repository/sqlite/session_branch.go +++ b/apps/backend/internal/task/repository/sqlite/session_branch.go @@ -10,7 +10,7 @@ import ( // on non-archived tasks. Used by the PR watch reconciler. func (r *Repository) ListSessionsWithBranches(ctx context.Context) ([]models.SessionBranchInfo, error) { rows, err := r.ro.QueryContext(ctx, ` - SELECT ts.id, ts.task_id, tsw.worktree_branch + SELECT ts.id, ts.task_id, t.workspace_id, tsw.worktree_branch FROM task_sessions ts INNER JOIN tasks t ON t.id = ts.task_id INNER JOIN task_session_worktrees tsw ON tsw.session_id = ts.id @@ -29,7 +29,7 @@ func (r *Repository) ListSessionsWithBranches(ctx context.Context) ([]models.Ses var result []models.SessionBranchInfo for rows.Next() { var info models.SessionBranchInfo - if err := rows.Scan(&info.SessionID, &info.TaskID, &info.Branch); err != nil { + if err := rows.Scan(&info.SessionID, &info.TaskID, &info.WorkspaceID, &info.Branch); err != nil { return nil, err } result = append(result, info) diff --git a/apps/backend/internal/task/service/repository_discovery.go b/apps/backend/internal/task/service/repository_discovery.go index 1150f1652f..c2170ff249 100644 --- a/apps/backend/internal/task/service/repository_discovery.go +++ b/apps/backend/internal/task/service/repository_discovery.go @@ -349,7 +349,9 @@ func (s *Service) listRemoteBranchesIfApplicable(ctx context.Context, repoID str if repo.SourceType == sourceTypeLocal || repo.ProviderOwner == "" || repo.ProviderName == "" { return nil, false, nil } - branches, err := s.remoteBranchLister.ListRepoBranches(ctx, repo.ProviderOwner, repo.ProviderName) + branches, err := s.remoteBranchLister.ListRepoBranches( + ctx, repo.WorkspaceID, repo.ProviderOwner, repo.ProviderName, + ) return branches, true, err } diff --git a/apps/backend/internal/task/service/repository_discovery_test.go b/apps/backend/internal/task/service/repository_discovery_test.go index ae8f3d3bd7..56c0ed8520 100644 --- a/apps/backend/internal/task/service/repository_discovery_test.go +++ b/apps/backend/internal/task/service/repository_discovery_test.go @@ -677,13 +677,15 @@ func newDiscoveryService(t *testing.T, root string) *Service { // stubRemoteLister captures the call args and returns canned branches/err. type stubRemoteLister struct { - branches []Branch - err error - calls int + branches []Branch + err error + calls int + workspaceID string } -func (s *stubRemoteLister) ListRepoBranches(_ context.Context, _, _ string) ([]Branch, error) { +func (s *stubRemoteLister) ListRepoBranches(_ context.Context, workspaceID, _, _ string) ([]Branch, error) { s.calls++ + s.workspaceID = workspaceID return s.branches, s.err } @@ -724,6 +726,9 @@ func TestListBranches_RoutesProviderRepoToRemoteLister(t *testing.T) { if lister.calls != 1 { t.Fatalf("remote lister calls = %d, want 1", lister.calls) } + if lister.workspaceID != "ws-1" { + t.Fatalf("remote lister workspace = %q, want ws-1", lister.workspaceID) + } if len(got) != 2 || got[0].Name != "main" || got[1].Name != "develop" { t.Fatalf("unexpected branches: %+v", got) } diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index efe12bb5fb..9f5e663fb3 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -385,7 +385,7 @@ func (s *Service) SetQuickChatDir(dir string) { // registered as remote ("Remote" badge in the UI) can serve branches before // or even without the orchestrator finishing its clone. type RemoteBranchLister interface { - ListRepoBranches(ctx context.Context, owner, repo string) ([]Branch, error) + ListRepoBranches(ctx context.Context, workspaceID, owner, repo string) ([]Branch, error) } // SetRemoteBranchLister wires the remote branch source. Currently only GitHub diff --git a/apps/backend/internal/task/share/backend.go b/apps/backend/internal/task/share/backend.go index 5a5ed6d99e..0ea5b9cbdf 100644 --- a/apps/backend/internal/task/share/backend.go +++ b/apps/backend/internal/task/share/backend.go @@ -12,6 +12,10 @@ import ( // per-file size cap. The HTTP handler maps this to 413. var ErrSnapshotTooLarge = errors.New("snapshot exceeds maximum size") +// ErrWorkspaceRequired prevents share operations from falling back to an +// installation-global GitHub identity when task ownership is missing. +var ErrWorkspaceRequired = errors.New("task workspace is required for GitHub sharing") + // Backend is the contract every storage backend must implement. The snapshot // has already been redacted by the caller; the backend is responsible only // for transport and persistence. @@ -21,11 +25,15 @@ type Backend interface { // Upload publishes the snapshot and returns the backend-specific // identifier (e.g. gist ID) and the public URL where it can be viewed. - Upload(ctx context.Context, snap *Snapshot) (externalID, externalURL string, err error) + Upload(ctx context.Context, workspaceID string, snap *Snapshot) (externalID, externalURL string, err error) // Delete removes a previously-uploaded snapshot. Backends MAY return a // "not found" error which the service detects via IsAlreadyGone. - Delete(ctx context.Context, externalID string) error + Delete(ctx context.Context, workspaceID, externalID string) error + + // CheckAccess validates that the workspace-owned principal can use this + // backend without performing the upload. + CheckAccess(ctx context.Context, workspaceID string) error } // IsAlreadyGone reports whether err signals the backend resource no longer diff --git a/apps/backend/internal/task/share/gist_backend.go b/apps/backend/internal/task/share/gist_backend.go index f4727f57e0..faaae8e002 100644 --- a/apps/backend/internal/task/share/gist_backend.go +++ b/apps/backend/internal/task/share/gist_backend.go @@ -20,7 +20,14 @@ const GistMaxBytes = 10 * 1024 * 1024 // account. Created gists are always secret (unlisted); anyone with the URL // can view them but they are not crawled. type GistBackend struct { - client github.Client + client github.Client + resolver GitHubClientResolver +} + +// GitHubClientResolver resolves the automation principal selected by a +// workspace. Implementations must fail closed for missing workspace IDs. +type GitHubClientResolver interface { + ResolveGitHubAutomationClient(context.Context, string) (github.Client, error) } // NewGistBackend returns a Backend that uses the given github.Client. @@ -28,6 +35,12 @@ func NewGistBackend(client github.Client) *GistBackend { return &GistBackend{client: client} } +// NewWorkspaceGistBackend returns a Gist backend that resolves a client for +// each owning workspace instead of retaining an installation-global client. +func NewWorkspaceGistBackend(resolver GitHubClientResolver) *GistBackend { + return &GistBackend{resolver: resolver} +} + // Name implements Backend. func (b *GistBackend) Name() string { return BackendGitHubGist } @@ -36,7 +49,7 @@ func (b *GistBackend) Name() string { return BackendGitHubGist } // the rendered view served via gist.githack.com — that's the link users // share. The gist itself is preserved in the database via the externalID // so we can still delete it on revoke. -func (b *GistBackend) Upload(ctx context.Context, snap *Snapshot) (string, string, error) { +func (b *GistBackend) Upload(ctx context.Context, workspaceID string, snap *Snapshot) (string, string, error) { body, err := json.MarshalIndent(snap, "", " ") if err != nil { return "", "", fmt.Errorf("marshal snapshot: %w", err) @@ -44,7 +57,11 @@ func (b *GistBackend) Upload(ctx context.Context, snap *Snapshot) (string, strin if len(body) > GistMaxBytes { return "", "", fmt.Errorf("%w: %d bytes > %d", ErrSnapshotTooLarge, len(body), GistMaxBytes) } - resp, err := b.client.CreateGist(ctx, github.CreateGistInput{ + client, err := b.clientFor(ctx, workspaceID) + if err != nil { + return "", "", err + } + resp, err := client.CreateGist(ctx, github.CreateGistInput{ Description: gistDescription(snap), Public: false, Files: map[string]github.GistFile{ @@ -143,8 +160,12 @@ func ownerAndIDFromGithackURL(url string) (string, string) { // Delete removes the gist. A 404 from GitHub is returned to the caller as-is // so the service can treat it as "already gone". -func (b *GistBackend) Delete(ctx context.Context, externalID string) error { - if err := b.client.DeleteGist(ctx, externalID); err != nil { +func (b *GistBackend) Delete(ctx context.Context, workspaceID, externalID string) error { + client, err := b.clientFor(ctx, workspaceID) + if err != nil { + return err + } + if err := client.DeleteGist(ctx, externalID); err != nil { var apiErr *github.GitHubAPIError if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { return err @@ -154,6 +175,42 @@ func (b *GistBackend) Delete(ctx context.Context, externalID string) error { return nil } +// CheckAccess verifies the resolved workspace client before share creation. +func (b *GistBackend) CheckAccess(ctx context.Context, workspaceID string) error { + client, err := b.clientFor(ctx, workspaceID) + if err != nil { + return err + } + authenticated, err := client.IsAuthenticated(ctx) + if err != nil { + return err + } + if !authenticated { + return errors.New("connect a GitHub account to share tasks publicly") + } + return nil +} + +func (b *GistBackend) clientFor(ctx context.Context, workspaceID string) (github.Client, error) { + if strings.TrimSpace(workspaceID) == "" { + return nil, ErrWorkspaceRequired + } + if b != nil && b.resolver != nil { + client, err := b.resolver.ResolveGitHubAutomationClient(ctx, workspaceID) + if err != nil { + return nil, err + } + if client == nil { + return nil, errors.New("github client resolver returned no client") + } + return client, nil + } + if b == nil || b.client == nil { + return nil, errors.New("github client is not configured") + } + return b.client, nil +} + func gistDescription(snap *Snapshot) string { if snap == nil || snap.Task.Title == "" { return "kandev task share" diff --git a/apps/backend/internal/task/share/gist_backend_test.go b/apps/backend/internal/task/share/gist_backend_test.go index afd2887fd8..07887c3b6d 100644 --- a/apps/backend/internal/task/share/gist_backend_test.go +++ b/apps/backend/internal/task/share/gist_backend_test.go @@ -10,6 +10,24 @@ import ( "github.com/kandev/kandev/internal/github" ) +type recordingGitHubResolver struct { + clients map[string]github.Client + workspaces []string + allowNil bool +} + +func (r *recordingGitHubResolver) ResolveGitHubAutomationClient( + _ context.Context, + workspaceID string, +) (github.Client, error) { + r.workspaces = append(r.workspaces, workspaceID) + client := r.clients[workspaceID] + if client == nil && !r.allowNil { + return nil, errors.New("workspace not connected") + } + return client, nil +} + func sampleSnapshot() *Snapshot { completed := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) return &Snapshot{ @@ -35,7 +53,7 @@ func TestGistBackend_Upload_CreatesSecretGistWithExpectedFiles(t *testing.T) { mock := github.NewMockClient() b := NewGistBackend(mock) - id, url, err := b.Upload(context.Background(), sampleSnapshot()) + id, url, err := b.Upload(context.Background(), "workspace-1", sampleSnapshot()) if err != nil { t.Fatalf("upload: %v", err) } @@ -88,7 +106,7 @@ func TestGistBackend_Upload_RejectsOversizedSnapshot(t *testing.T) { big := strings.Repeat("x", GistMaxBytes+1) snap.Messages = []Message{{Role: "user", Blocks: []Block{{Kind: "text", Text: big}}}} - _, _, err := b.Upload(context.Background(), snap) + _, _, err := b.Upload(context.Background(), "workspace-1", snap) if err == nil { t.Fatal("expected ErrSnapshotTooLarge, got nil") } @@ -104,11 +122,11 @@ func TestGistBackend_Delete_Success(t *testing.T) { t.Parallel() mock := github.NewMockClient() b := NewGistBackend(mock) - id, _, err := b.Upload(context.Background(), sampleSnapshot()) + id, _, err := b.Upload(context.Background(), "workspace-1", sampleSnapshot()) if err != nil { t.Fatalf("upload: %v", err) } - if err := b.Delete(context.Background(), id); err != nil { + if err := b.Delete(context.Background(), "workspace-1", id); err != nil { t.Fatalf("delete: %v", err) } if got := mock.DeletedGists(); len(got) != 1 || got[0] != id { @@ -121,7 +139,7 @@ func TestGistBackend_Delete_PassesThroughNotFoundForCallerInspection(t *testing. mock := github.NewMockClient() b := NewGistBackend(mock) - err := b.Delete(context.Background(), "missing-id") + err := b.Delete(context.Background(), "workspace-1", "missing-id") if err == nil { t.Fatal("expected error for missing gist") } @@ -129,3 +147,40 @@ func TestGistBackend_Delete_PassesThroughNotFoundForCallerInspection(t *testing. t.Fatalf("expected IsAlreadyGone to be true, got err=%v", err) } } + +func TestGistBackend_ResolvesOwningWorkspaceForEveryOperation(t *testing.T) { + t.Parallel() + first := github.NewMockClient() + second := github.NewMockClient() + resolver := &recordingGitHubResolver{clients: map[string]github.Client{ + "workspace-1": first, + "workspace-2": second, + }} + backend := NewWorkspaceGistBackend(resolver) + firstID, _, err := backend.Upload(context.Background(), "workspace-1", sampleSnapshot()) + if err != nil { + t.Fatalf("workspace-1 upload: %v", err) + } + if _, _, err := backend.Upload(context.Background(), "workspace-2", sampleSnapshot()); err != nil { + t.Fatalf("workspace-2 upload: %v", err) + } + if err := backend.Delete(context.Background(), "workspace-1", firstID); err != nil { + t.Fatalf("workspace-1 delete: %v", err) + } + if len(first.Gists()) != 0 || len(second.Gists()) != 1 || + len(resolver.workspaces) != 3 || resolver.workspaces[2] != "workspace-1" { + t.Fatalf("resolver workspaces = %v, first gists = %d, second gists = %d", + resolver.workspaces, len(first.Gists()), len(second.Gists())) + } +} + +func TestGistBackend_FailsClosedWhenResolverReturnsNoClient(t *testing.T) { + t.Parallel() + backend := NewWorkspaceGistBackend(&recordingGitHubResolver{ + clients: map[string]github.Client{}, + allowNil: true, + }) + if _, _, err := backend.Upload(context.Background(), "workspace-1", sampleSnapshot()); err == nil { + t.Fatal("expected upload to fail when the workspace resolver returns no client") + } +} diff --git a/apps/backend/internal/task/share/http.go b/apps/backend/internal/task/share/http.go index 4a55cf2167..2ae397408f 100644 --- a/apps/backend/internal/task/share/http.go +++ b/apps/backend/internal/task/share/http.go @@ -1,7 +1,6 @@ package share import ( - "context" "errors" "net/http" "strings" @@ -10,22 +9,19 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/internal/github" ) // HTTPHandlers wires the share endpoints. RegisterRoutes is the public entry // point used by cmd/kandev. type HTTPHandlers struct { - svc *Service - ghClient github.Client - log *logger.Logger + svc *Service + log *logger.Logger } // NewHTTPHandlers returns handlers that delegate to the share Service. The -// github.Client is used only for the upfront IsAuthenticated probe so we -// can return a precondition-failed error instead of letting Upload fail. -func NewHTTPHandlers(svc *Service, ghClient github.Client, log *logger.Logger) *HTTPHandlers { - return &HTTPHandlers{svc: svc, ghClient: ghClient, log: log} +// service resolves workspace-owned backend access for the upfront probe. +func NewHTTPHandlers(svc *Service, log *logger.Logger) *HTTPHandlers { + return &HTTPHandlers{svc: svc, log: log} } // RegisterRoutes wires the share endpoints onto the given gin engine under @@ -132,7 +128,7 @@ func (h *HTTPHandlers) httpCreate(c *gin.Context) { return } - if err := h.requireGitHubAuth(ctx); err != nil { + if err := h.svc.CheckBackendAccess(ctx, sessionID); err != nil { c.JSON(http.StatusPreconditionFailed, errorBody{ Error: err.Error(), Code: "github_credential_missing", @@ -191,24 +187,6 @@ func (h *HTTPHandlers) httpRevoke(c *gin.Context) { c.Status(http.StatusNoContent) } -// requireGitHubAuth returns nil when the client reports an authenticated -// session, or an explanatory error otherwise. A transport-layer failure is -// logged and treated as "not authenticated" so the user still sees a clean -// 412 with a CTA, not a 5xx. -func (h *HTTPHandlers) requireGitHubAuth(ctx context.Context) error { - if h.ghClient == nil { - return errors.New("github client is not configured") - } - ok, err := h.ghClient.IsAuthenticated(ctx) - if err != nil { - return err - } - if !ok { - return errors.New("connect a GitHub account to share tasks publicly") - } - return nil -} - // mapShareError translates a service error into the response body + status // code. Returns (nil, 0) when err is nil. The status table is part of the // public contract; keep it in sync with docs/specs/public-share-links/spec.md. diff --git a/apps/backend/internal/task/share/http_test.go b/apps/backend/internal/task/share/http_test.go index c0c22acb6a..1d3807f0fa 100644 --- a/apps/backend/internal/task/share/http_test.go +++ b/apps/backend/internal/task/share/http_test.go @@ -3,6 +3,7 @@ package share import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -10,37 +11,19 @@ import ( "github.com/gin-gonic/gin" - "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/task/models" ) func init() { gin.SetMode(gin.TestMode) } -// stubGHAuth lets tests toggle the IsAuthenticated response without owning -// the full Client interface. -type stubGHAuth struct { - authed bool -} - -func (s *stubGHAuth) IsAuthenticated(context.Context) (bool, error) { return s.authed, nil } - -// embedNoop fills the rest of the Client surface with the noop implementation -// so we don't have to re-implement 30 methods just for a handler test. -type embedNoop struct { - *stubGHAuth - *github.NoopClient -} - -func (e *embedNoop) IsAuthenticated(ctx context.Context) (bool, error) { - return e.stubGHAuth.IsAuthenticated(ctx) -} - func newHandlersForTest(t *testing.T, reader TaskReader, backend Backend, authed bool) (*HTTPHandlers, *Service) { t.Helper() repo := newTestRepo(t) svc := New(repo, reader, backend, nil, "v-test") - gh := &embedNoop{stubGHAuth: &stubGHAuth{authed: authed}, NoopClient: &github.NoopClient{}} - return NewHTTPHandlers(svc, gh, nil), svc + if mock, ok := backend.(*mockBackend); ok && !authed { + mock.accessErr = errors.New("connect a GitHub account to share tasks publicly") + } + return NewHTTPHandlers(svc, nil), svc } func newGinRouter(h *HTTPHandlers) *gin.Engine { @@ -72,6 +55,9 @@ func TestHTTP_Create_HappyPath(t *testing.T) { if body.URL != "https://gist.githack.com/jane/gist-x/raw/share.html" { t.Fatalf("unexpected url: %s", body.URL) } + if len(backend.accessWorkspaces) != 1 || backend.accessWorkspaces[0] != "workspace-1" { + t.Fatalf("access workspaces = %v, want workspace-1", backend.accessWorkspaces) + } } func TestHTTP_Create_RejectsCreatedSessionWith409(t *testing.T) { diff --git a/apps/backend/internal/task/share/provider.go b/apps/backend/internal/task/share/provider.go index 4032b9f2a5..8823d43ceb 100644 --- a/apps/backend/internal/task/share/provider.go +++ b/apps/backend/internal/task/share/provider.go @@ -6,7 +6,6 @@ import ( "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/internal/github" ) // Config is the boot-time configuration for the share package. KandevVersion @@ -18,13 +17,13 @@ type Config struct { // Provide wires the share package's repository, service, and HTTP handlers. // The taskReader is typically *sqliterepo.Repository from the task package; -// only the three methods on TaskReader are used. The github.Client may be -// nil, in which case CreateShare will fail at the IsAuthenticated probe. +// only the three methods on TaskReader are used. The GitHub resolver selects +// the owning workspace's automation principal for each operation. // Cleanup is a no-op; the repository does not own its database connection. func Provide( writer, reader *sqlx.DB, taskReader TaskReader, - ghClient github.Client, + githubResolver GitHubClientResolver, log *logger.Logger, cfg Config, ) (*HTTPHandlers, func() error, error) { @@ -32,9 +31,9 @@ func Provide( if err != nil { return nil, nil, fmt.Errorf("share: provide repository: %w", err) } - backend := NewGistBackend(ghClient) + backend := NewWorkspaceGistBackend(githubResolver) svc := New(repo, taskReader, backend, log, cfg.KandevVersion) - h := NewHTTPHandlers(svc, ghClient, log) + h := NewHTTPHandlers(svc, log) // Cleanup is a true no-op — the repository doesn't own its database // connection (the pool is owned by cmd/kandev). return h, func() error { return nil }, nil diff --git a/apps/backend/internal/task/share/service.go b/apps/backend/internal/task/share/service.go index 234eda64bf..888719cb09 100644 --- a/apps/backend/internal/task/share/service.go +++ b/apps/backend/internal/task/share/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" "github.com/google/uuid" @@ -42,6 +43,10 @@ func (s *Service) PreviewSnapshot(ctx context.Context, taskSessionID string) (*S // CreateShare builds a snapshot, uploads it via the configured backend, and // records the row in the repository. func (s *Service) CreateShare(ctx context.Context, taskSessionID string) (*Share, error) { + workspaceID, err := s.workspaceForSession(ctx, taskSessionID) + if err != nil { + return nil, err + } snap, err := BuildSnapshot(ctx, s.taskRepo, taskSessionID, s.kandevVer) if err != nil { return nil, err @@ -52,7 +57,7 @@ func (s *Service) CreateShare(ctx context.Context, taskSessionID string) (*Share if err != nil { return nil, fmt.Errorf("marshal snapshot: %w", err) } - extID, extURL, err := s.backend.Upload(ctx, snap) + extID, extURL, err := s.backend.Upload(ctx, workspaceID, snap) if err != nil { return nil, fmt.Errorf("upload snapshot: %w", err) } @@ -69,7 +74,7 @@ func (s *Service) CreateShare(ctx context.Context, taskSessionID string) (*Share // The gist exists on GitHub but we failed to persist the row. // Best-effort: try to clean up the orphaned gist; log if that // also fails so the user can manually delete it. - if delErr := s.backend.Delete(ctx, extID); delErr != nil { + if delErr := s.backend.Delete(ctx, workspaceID, extID); delErr != nil { s.logWarn("orphaned gist after row insert failed", zap.String("gist_id", extID), zap.Error(delErr)) } @@ -89,7 +94,11 @@ func (s *Service) RevokeShare(ctx context.Context, shareID string) error { if row.IsRevoked() { return nil } - if delErr := s.backend.Delete(ctx, row.ExternalID); delErr != nil { + workspaceID, err := s.workspaceForSession(ctx, row.TaskSessionID) + if err != nil { + return err + } + if delErr := s.backend.Delete(ctx, workspaceID, row.ExternalID); delErr != nil { if !IsAlreadyGone(delErr) { return fmt.Errorf("delete from backend: %w", delErr) } @@ -105,6 +114,38 @@ func (s *Service) RevokeShare(ctx context.Context, shareID string) error { return nil } +// CheckBackendAccess resolves the task's workspace and validates its backend +// identity. Dry-run previews intentionally do not call this method. +func (s *Service) CheckBackendAccess(ctx context.Context, taskSessionID string) error { + workspaceID, err := s.workspaceForSession(ctx, taskSessionID) + if err != nil { + return err + } + return s.backend.CheckAccess(ctx, workspaceID) +} + +func (s *Service) workspaceForSession(ctx context.Context, taskSessionID string) (string, error) { + session, err := s.taskRepo.GetTaskSession(ctx, taskSessionID) + if err != nil { + return "", fmt.Errorf("load session workspace: %w", err) + } + if session == nil { + return "", ErrNotFound + } + task, err := s.taskRepo.GetTask(ctx, session.TaskID) + if err != nil { + return "", fmt.Errorf("load task workspace: %w", err) + } + if task == nil { + return "", ErrNotFound + } + workspaceID := strings.TrimSpace(task.WorkspaceID) + if workspaceID == "" { + return "", ErrWorkspaceRequired + } + return workspaceID, nil +} + // ListBySession returns every share row for a session (including revoked). func (s *Service) ListBySession(ctx context.Context, taskSessionID string) ([]*Share, error) { return s.repo.ListByTaskSession(ctx, taskSessionID) diff --git a/apps/backend/internal/task/share/service_test.go b/apps/backend/internal/task/share/service_test.go index c062101e1f..a1385f6b92 100644 --- a/apps/backend/internal/task/share/service_test.go +++ b/apps/backend/internal/task/share/service_test.go @@ -14,21 +14,31 @@ import ( // mockBackend records uploads and supports configurable upload/delete errors. type mockBackend struct { - uploads int - deletes []string - uploadErr error - deleteErr error - nextID string - nextURL string + uploads int + uploadWorkspaces []string + deletes []string + deleteWorkspaces []string + accessWorkspaces []string + uploadErr error + deleteErr error + accessErr error + nextID string + nextURL string } func (m *mockBackend) Name() string { return BackendGitHubGist } -func (m *mockBackend) Upload(_ context.Context, _ *Snapshot) (string, string, error) { +func (m *mockBackend) CheckAccess(_ context.Context, workspaceID string) error { + m.accessWorkspaces = append(m.accessWorkspaces, workspaceID) + return m.accessErr +} + +func (m *mockBackend) Upload(_ context.Context, workspaceID string, _ *Snapshot) (string, string, error) { if m.uploadErr != nil { return "", "", m.uploadErr } m.uploads++ + m.uploadWorkspaces = append(m.uploadWorkspaces, workspaceID) id := m.nextID if id == "" { id = "gist-1" @@ -40,18 +50,19 @@ func (m *mockBackend) Upload(_ context.Context, _ *Snapshot) (string, string, er return id, url, nil } -func (m *mockBackend) Delete(_ context.Context, id string) error { +func (m *mockBackend) Delete(_ context.Context, workspaceID, id string) error { if m.deleteErr != nil { return m.deleteErr } m.deletes = append(m.deletes, id) + m.deleteWorkspaces = append(m.deleteWorkspaces, workspaceID) return nil } func completedSession() *stubReader { completed := time.Now().UTC() return &stubReader{ - task: &models.Task{ID: "t-1", Title: "test task"}, + task: &models.Task{ID: "t-1", WorkspaceID: "workspace-1", Title: "test task"}, session: &models.TaskSession{ ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted, StartedAt: completed.Add(-time.Minute), CompletedAt: &completed, @@ -75,6 +86,9 @@ func TestService_CreateShare_HappyPath(t *testing.T) { if share.ExternalID != "abc" || share.ExternalURL != "https://gist.github.com/u/abc" { t.Fatalf("unexpected share: %+v", share) } + if len(mock.uploadWorkspaces) != 1 || mock.uploadWorkspaces[0] != "workspace-1" { + t.Fatalf("upload workspaces = %v, want workspace-1", mock.uploadWorkspaces) + } if share.SnapshotSizeBytes <= 0 { t.Fatalf("expected non-zero snapshot size, got %d", share.SnapshotSizeBytes) } @@ -159,6 +173,9 @@ func TestService_RevokeShare_DeletesAndMarksRevoked(t *testing.T) { if len(mock.deletes) != 1 || mock.deletes[0] != "abc" { t.Fatalf("expected backend Delete(abc), got %v", mock.deletes) } + if len(mock.deleteWorkspaces) != 1 || mock.deleteWorkspaces[0] != "workspace-1" { + t.Fatalf("delete workspaces = %v, want workspace-1", mock.deleteWorkspaces) + } got, err := repo.GetByID(context.Background(), share.ID) if err != nil { t.Fatalf("get: %v", err) @@ -168,6 +185,19 @@ func TestService_RevokeShare_DeletesAndMarksRevoked(t *testing.T) { } } +func TestService_CreateShare_FailsClosedWithoutTaskWorkspace(t *testing.T) { + t.Parallel() + repo := newTestRepo(t) + reader := completedSession() + reader.task.WorkspaceID = "" + mock := &mockBackend{} + svc := New(repo, reader, mock, nil, "v") + _, err := svc.CreateShare(context.Background(), "s-1") + if !errors.Is(err, ErrWorkspaceRequired) || mock.uploads != 0 { + t.Fatalf("CreateShare() error = %v, uploads = %d", err, mock.uploads) + } +} + func TestService_RevokeShare_IsIdempotentOnSecondCall(t *testing.T) { t.Parallel() repo := newTestRepo(t) diff --git a/apps/backend/internal/workflowsync/service.go b/apps/backend/internal/workflowsync/service.go index 607cf51d38..ce286318ee 100644 --- a/apps/backend/internal/workflowsync/service.go +++ b/apps/backend/internal/workflowsync/service.go @@ -28,11 +28,14 @@ type Applier interface { ReleaseSyncedWorkflows(ctx context.Context, workspaceID string) ([]string, error) } -// ClientProvider hands out the GitHub client used to read the sync repo. -// Satisfied by the GitHub service; Client() may return nil when GitHub is not -// authenticated. +// ClientProvider exposes workspace-routed GitHub repository reads. type ClientProvider interface { - Client() github.Client + ListRepoDirectoryForWorkspace( + ctx context.Context, workspaceID, owner, repo, path, ref string, + ) ([]github.RepoContentEntry, error) + GetRepoFileContentForWorkspace( + ctx context.Context, workspaceID, owner, repo, path, ref string, + ) ([]byte, error) } // Service owns workflow sync configuration and sync execution. @@ -177,11 +180,12 @@ func (s *Service) recordFailure(ctx context.Context, workspaceID string, syncErr // fetchFiles lists the configured directory and downloads every workflow // definition file in it (non-recursive). func (s *Service) fetchFiles(ctx context.Context, cfg *Config) ([]fetchedFile, error) { - client := s.clients.Client() - if client == nil { + if s.clients == nil { return nil, fmt.Errorf("GitHub is not authenticated; configure a GitHub token to sync workflows") } - entries, err := client.ListRepoDirectory(ctx, cfg.RepoOwner, cfg.RepoName, cfg.Path, cfg.Branch) + entries, err := s.clients.ListRepoDirectoryForWorkspace( + ctx, cfg.WorkspaceID, cfg.RepoOwner, cfg.RepoName, cfg.Path, cfg.Branch, + ) if err != nil { return nil, fmt.Errorf("failed to list %s/%s@%s:%s: %w", cfg.RepoOwner, cfg.RepoName, cfg.Branch, cfg.Path, err) } @@ -190,7 +194,9 @@ func (s *Service) fetchFiles(ctx context.Context, cfg *Config) ([]fetchedFile, e if entry.Type != "file" || !isSyncableFile(entry.Name) { continue } - content, err := client.GetRepoFileContent(ctx, cfg.RepoOwner, cfg.RepoName, entry.Path, cfg.Branch) + content, err := s.clients.GetRepoFileContentForWorkspace( + ctx, cfg.WorkspaceID, cfg.RepoOwner, cfg.RepoName, entry.Path, cfg.Branch, + ) if err != nil { return nil, fmt.Errorf("failed to fetch %s: %w", entry.Path, err) } diff --git a/apps/backend/internal/workflowsync/service_test.go b/apps/backend/internal/workflowsync/service_test.go index 96f48ed3d3..7d21c57812 100644 --- a/apps/backend/internal/workflowsync/service_test.go +++ b/apps/backend/internal/workflowsync/service_test.go @@ -17,7 +17,23 @@ type fakeClients struct { client github.Client } -func (f fakeClients) Client() github.Client { return f.client } +func (f fakeClients) ListRepoDirectoryForWorkspace( + ctx context.Context, _ string, owner, repo, path, ref string, +) ([]github.RepoContentEntry, error) { + if f.client == nil { + return nil, github.ErrNoClient + } + return f.client.ListRepoDirectory(ctx, owner, repo, path, ref) +} + +func (f fakeClients) GetRepoFileContentForWorkspace( + ctx context.Context, _ string, owner, repo, path, ref string, +) ([]byte, error) { + if f.client == nil { + return nil, github.ErrNoClient + } + return f.client.GetRepoFileContent(ctx, owner, repo, path, ref) +} // fakeApplier is mutex-guarded because the poller invokes it from its own // goroutine while tests assert on call counts (-race catches unguarded use). @@ -188,7 +204,7 @@ func TestSyncWorkspace_NilClientRecordsFailure(t *testing.T) { _, err := svc.SyncWorkspace(context.Background(), "ws-1") require.Error(t, err) - assert.Contains(t, err.Error(), "not authenticated") + assert.Contains(t, err.Error(), "not configured") cfg, cfgErr := svc.GetConfigForWorkspace(context.Background(), "ws-1") require.NoError(t, cfgErr) diff --git a/apps/web/app/github/github-page-client.tsx b/apps/web/app/github/github-page-client.tsx index e458b6acfb..77d9d5ed47 100644 --- a/apps/web/app/github/github-page-client.tsx +++ b/apps/web/app/github/github-page-client.tsx @@ -47,6 +47,7 @@ import { } from "@/components/github/my-github/action-presets"; import { useGitHubActionPresets } from "@/hooks/domains/github/use-github-action-presets"; import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; +import { hasGitHubPersonalActor } from "@/lib/github-auth"; type GitHubPageClientProps = { workspaceId?: string; @@ -79,14 +80,24 @@ function PageHeader({ onOpenMobileSidebar }: { onOpenMobileSidebar?: () => void ); } -function NotAuthenticatedNotice() { +function NotAuthenticatedNotice({ + workspaceId, + personalRequired, +}: { + workspaceId?: string; + personalRequired: boolean; +}) { + const settingsHref = workspaceId + ? `/settings/workspace/${workspaceId}/integrations/github` + : "/settings/integrations/github"; return ( - GitHub is not connected. Configure GitHub authentication (gh CLI or a Personal Access Token) - in{" "} - - Settings → GitHub + {personalRequired + ? "Connect your personal GitHub identity to see pull requests and issues assigned to you." + : "GitHub is not connected. Configure workspace automation with gh CLI or a personal access token."}{" "} + + Open GitHub settings {" "} to see your pull requests and issues. @@ -106,6 +117,10 @@ function NoWorkspaceNotice() { ); } +function hasGitHubUserIdentity(status: ReturnType["status"]) { + return hasGitHubPersonalActor(status); +} + function resolveTitle( selection: SidebarSelection, saved: SavedPreset[], @@ -123,6 +138,7 @@ function resolveTitle( } function ResultsList({ + workspaceId, selection, items, loading, @@ -133,6 +149,7 @@ function ResultsList({ prKeyToTasks, issueKeyToTasks, }: { + workspaceId: string | null; selection: SidebarSelection; items: Array; loading: boolean; @@ -146,6 +163,7 @@ function ResultsList({ if (selection.kind === "pr") { return ( ({ + enabled, kind: selection.kind, presets, preset: selection.source === "preset" ? selection.id : "", @@ -445,6 +464,7 @@ function AuthenticatedLayout({ {!workspaceId && }
(null); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - const state = useGitHubPageState(workspaceId ?? null); + const state = useGitHubPageState(workspaceId ?? null, authed); const { presets: storedPresets } = useGitHubActionPresets(workspaceId ?? null); const prPresets = useMemo(() => resolvePRPresets(storedPresets), [storedPresets]); const issuePresets = useMemo(() => resolveIssuePresets(storedPresets), [storedPresets]); @@ -486,7 +507,6 @@ export function GitHubPageClient({ const onStartTask = useCallback((payload: LaunchPayload) => setLaunchPayload(payload), []); const onCloseLaunch = useCallback(() => setLaunchPayload(null), []); - const authed = !!status?.authenticated; const onOpenMobileSidebar = useCallback(() => setMobileSidebarOpen(true), []); // Close the mobile sheet after any sidebar selection. KindToggle clicks also // route through onSelect — closing on every selection is acceptable UX since @@ -508,7 +528,10 @@ export function GitHubPageClient({ {!loaded &&
Checking GitHub status…
} {loaded && !authed && (
- +
)} {loaded && authed && ( diff --git a/apps/web/app/settings/executors/new/[type]/page.tsx b/apps/web/app/settings/executors/new/[type]/page.tsx index f05264ca28..ad845035a6 100644 --- a/apps/web/app/settings/executors/new/[type]/page.tsx +++ b/apps/web/app/settings/executors/new/[type]/page.tsx @@ -219,10 +219,8 @@ function useCreateRemoteFlags(executorType: ExecutorType) { }; } -function useCreateRemoteAuthState(executorType: ExecutorType) { - const [remoteCredentials, setRemoteCredentials] = useState(() => - executorType === "sprites" ? ["gh_cli_token"] : [], - ); +function useCreateRemoteAuthState() { + const [remoteCredentials, setRemoteCredentials] = useState([]); const [agentEnvVars, setAgentEnvVars] = useState>({}); const [networkPolicyRules, setNetworkPolicyRules] = useState([]); @@ -290,7 +288,7 @@ function useCreateProfileFormState(executorType: ExecutorType) { const { envVarRows, addEnvVar, removeEnvVar, updateEnvVar } = useEnvVarRows([]); const [placeholders, setPlaceholders] = useState([]); const [spritesSecretId, setSpritesSecretId] = useState(null); - const remoteAuth = useCreateRemoteAuthState(executorType); + const remoteAuth = useCreateRemoteAuthState(); const [dockerfile, setDockerfile] = useState(""); const [imageTag, setImageTag] = useState(""); const [builtDockerImage, setBuiltDockerImage] = useState(null); diff --git a/apps/web/app/settings/integrations/github/page.tsx b/apps/web/app/settings/integrations/github/page.tsx index fba2c2d7de..5dc1ab7470 100644 --- a/apps/web/app/settings/integrations/github/page.tsx +++ b/apps/web/app/settings/integrations/github/page.tsx @@ -1,5 +1,6 @@ import { GitHubIntegrationPage } from "@/components/github/github-settings"; import { StateHydrator } from "@/components/state-hydrator"; +import { normalizeGitHubStatus } from "@/hooks/domains/github/use-github-status"; import { fetchGitHubStatus } from "@/lib/api/domains/github-api"; type IntegrationsGitHubPageProps = { @@ -9,16 +10,24 @@ type IntegrationsGitHubPageProps = { export default async function IntegrationsGitHubPage({ workspaceId, }: IntegrationsGitHubPageProps = {}) { - const status = await fetchGitHubStatus({ cache: "no-store" }).catch(() => null); - const initialState = status - ? { - githubStatus: { - status, - loaded: true, - loading: false, - }, - } - : {}; + const status = workspaceId + ? await fetchGitHubStatus(workspaceId, { cache: "no-store" }).catch(() => null) + : null; + const statusWorkspaceId = workspaceId ?? status?.workspace_id; + const initialState = + status && statusWorkspaceId + ? { + githubStatus: { + byWorkspaceId: { + [statusWorkspaceId]: { + status: normalizeGitHubStatus(status), + loaded: true, + loading: false, + }, + }, + }, + } + : {}; return ( <> diff --git a/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx index 885076005f..e8601ba9d7 100644 --- a/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx @@ -61,7 +61,7 @@ function WorkspaceIntegrationItems({ pathname: string; }) { const azureDevOps = useAzureDevOpsAvailable(workspaceId); - const { status: githubStatus } = useGitHubStatus(); + const { status: githubStatus } = useGitHubStatus(workspaceId); const gitlab = useGitLabAvailable(); const jira = useJiraAuthed(workspaceId); const linear = useLinearAuthed(workspaceId); diff --git a/apps/web/components/github/github-app-connection-panel.tsx b/apps/web/components/github/github-app-connection-panel.tsx new file mode 100644 index 0000000000..0c1b6a26f8 --- /dev/null +++ b/apps/web/components/github/github-app-connection-panel.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { IconExternalLink, IconPlus } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Spinner } from "@kandev/ui/spinner"; +import { useToast } from "@/components/toast-provider"; +import { useGitHubAppRegistrations } from "@/hooks/domains/github/use-github-app-registrations"; +import { GitHubAppCreateForm } from "./github-app-create-form"; +import { GitHubAppImportForm } from "./github-app-import-form"; +import { GitHubAppRegistrationList } from "./github-app-registration-list"; + +type AppView = "choose" | "import" | "create"; +type RegistrationHook = ReturnType; + +export function GitHubAppConnectionPanel({ workspaceId }: { workspaceId: string }) { + const registrations = useGitHubAppRegistrations(workspaceId); + const [view, setView] = useState("choose"); + const { selectedId, setSelectedId, selectedRegistration } = useAppRegistrationSelection( + workspaceId, + registrations, + ); + const { toast } = useToast(); + + useEffect(() => { + setView("choose"); + }, [workspaceId]); + + async function install() { + if (!selectedRegistration || selectedRegistration.status !== "active") return; + try { + const response = await registrations.startInstall(selectedRegistration.id); + const url = response.url ?? response.URL; + if (!url) throw new Error("GitHub did not return an installation URL"); + window.location.assign(url); + } catch (error) { + toast({ + description: error instanceof Error ? error.message : "App installation failed", + variant: "error", + }); + } + } + + if (view === "import") { + return ( +
+ setView("choose")} /> + { + setSelectedId(registrationId); + setView("choose"); + }} + /> +
+ ); + } + if (view === "create") { + return ( +
+ setView("choose")} /> + +
+ ); + } + return ( +
+
+

Choose a GitHub App

+

+ Use an App when automation needs its own identity, short-lived tokens, and + repository-level installation control. Setup is more involved and requires a publicly + reachable HTTPS URL. +

+
+ {registrations.loading ? ( +
+ Loading registered Apps... +
+ ) : ( + + )} + {registrations.error &&

{registrations.error}

} +
+ + + +
+

+ A registration can be reused across workspaces, or you can keep work and personal Apps + separate. Each workspace selects and installs its own credential. +

+
+ ); +} + +function useAppRegistrationSelection(workspaceId: string, registrations: RegistrationHook) { + const [selectedId, setSelectedId] = useState(""); + useEffect(() => setSelectedId(""), [workspaceId]); + useEffect(() => { + if (!registrations.loaded) return; + setSelectedId((current) => { + const currentRegistration = registrations.registrations.find(({ id }) => id === current); + if (currentRegistration?.status === "active") return current; + if (registrations.selected?.status === "active") return registrations.selected.id; + return registrations.registrations.find(({ status }) => status === "active")?.id ?? ""; + }); + }, [registrations.loaded, registrations.registrations, registrations.selected]); + const selectedRegistration = registrations.registrations.find(({ id }) => id === selectedId); + return { selectedId, setSelectedId, selectedRegistration }; +} + +function BackButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} diff --git a/apps/web/components/github/github-app-create-form.tsx b/apps/web/components/github/github-app-create-form.tsx new file mode 100644 index 0000000000..47fb192841 --- /dev/null +++ b/apps/web/components/github/github-app-create-form.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { IconExternalLink } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group"; +import { Spinner } from "@kandev/ui/spinner"; +import { useToast } from "@/components/toast-provider"; +import type { StartGitHubAppManifestResponse } from "@/lib/types/github"; +import { + appRegistrationError, + defaultVisibility, + submitManifestToGitHub, + validateRegistrationBasics, + type AppSetupErrors, +} from "./github-app-onboarding-model"; +import { GitHubAppPolicyDialog } from "./github-app-policy-dialog"; +import { GitHubAppVisibilityField } from "./github-app-visibility-field"; +import type { useGitHubAppRegistrations } from "@/hooks/domains/github/use-github-app-registrations"; + +type RegistrationHook = ReturnType; + +export function GitHubAppCreateForm({ + workspaceId, + registrations, +}: { + workspaceId: string; + registrations: RegistrationHook; +}) { + const [displayName, setDisplayName] = useState(""); + const [ownerType, setOwnerType] = useState<"user" | "organization">("organization"); + const [ownerLogin, setOwnerLogin] = useState(""); + const [publicBaseUrl, setPublicBaseUrl] = useState(""); + const [visibility, setVisibility] = useState(defaultVisibility); + const [errors, setErrors] = useState({}); + const [handoff, setHandoff] = useState(null); + const { toast } = useToast(); + + useEffect(() => { + setDisplayName(""); + setOwnerType("organization"); + setOwnerLogin(""); + setPublicBaseUrl(""); + setVisibility(defaultVisibility); + setErrors({}); + setHandoff(null); + }, [workspaceId]); + + async function prepare(event: React.FormEvent) { + event.preventDefault(); + const basics = validateRegistrationBasics({ + displayName, + ownerType, + ownerLogin, + publicBaseUrl, + }); + setErrors(basics.errors); + if (!basics.publicBaseUrl || Object.keys(basics.errors).length) return; + try { + setHandoff( + await registrations.startManifest({ + display_name: basics.displayName, + owner_type: ownerType, + owner_login: basics.ownerLogin, + visibility, + public_base_url: basics.publicBaseUrl, + }), + ); + } catch (error) { + const detail = appRegistrationError(error); + toast({ description: detail?.error ?? "GitHub App setup could not start", variant: "error" }); + } + } + + if (handoff) { + return setHandoff(null)} />; + } + + return ( + + ); +} + +function ManifestHandoff({ + handoff, + onEdit, +}: { + handoff: StartGitHubAppManifestResponse; + onEdit: () => void; +}) { + return ( +
+
+

GitHub is ready to create the App

+

+ GitHub will show the generated permissions and ask you to confirm. Returning from GitHub + adds the registration; installing it for this workspace remains an explicit step. +

+
+
+ + +
+
+ ); +} + +type CreateFieldsProps = { + displayName: string; + ownerType: "user" | "organization"; + ownerLogin: string; + publicBaseUrl: string; + visibility: "private" | "public"; + errors: AppSetupErrors; + mutating: boolean; + onDisplayName: (value: string) => void; + onOwnerType: (value: "user" | "organization") => void; + onOwnerLogin: (value: string) => void; + onPublicBaseUrl: (value: string) => void; + onVisibility: (value: "private" | "public") => void; + onSubmit: (event: React.FormEvent) => void; +}; + +function CreateAppFields(props: CreateFieldsProps) { + return ( +
+
+

Create a GitHub App

+

+ Kandev generates the exact App policy. You confirm creation on GitHub; Kandev then stores + this registration so it can be selected by a workspace. +

+
+
+ + props.onDisplayName(event.target.value)} + /> + + + props.onOwnerLogin(event.target.value)} + /> + +
+ props.onOwnerType(value as CreateFieldsProps["ownerType"])} + className="flex min-h-11 flex-wrap items-center gap-5" + > + + + + + props.onPublicBaseUrl(event.target.value)} + /> + +

+ GitHub must reach this HTTPS origin for signed webhooks and callbacks. +

+ +
+ + +
+ + ); +} + +function Field({ + label, + error, + children, +}: { + label: string; + error?: string; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/apps/web/components/github/github-app-import-fields.tsx b/apps/web/components/github/github-app-import-fields.tsx new file mode 100644 index 0000000000..0af4230b26 --- /dev/null +++ b/apps/web/components/github/github-app-import-fields.tsx @@ -0,0 +1,131 @@ +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group"; +import { Textarea } from "@kandev/ui/textarea"; +import type { GitHubAppOwnerType, GitHubAppVisibility } from "@/lib/types/github"; +import type { AppSetupErrors } from "./github-app-onboarding-model"; + +export type GitHubAppImportValues = { + displayName: string; + ownerLogin: string; + ownerType: GitHubAppOwnerType; + publicBaseUrl: string; + visibility: GitHubAppVisibility; + appId: string; + clientId: string; + clientSecret: string; + privateKey: string; + webhookSecret: string; + slug: string; +}; + +type Update = (name: keyof GitHubAppImportValues, value: string) => void; + +export function GitHubAppImportIdentityFields({ + values, + errors, + update, +}: { + values: GitHubAppImportValues; + errors: AppSetupErrors; + update: Update; +}) { + return ( +
+
+ + update("displayName", e.target.value)} + /> + + + update("slug", e.target.value)} + /> + + + update("ownerLogin", e.target.value)} + /> + +
+ update("ownerType", value)} + className="flex min-h-11 flex-wrap items-center gap-5" + > + + + +
+ ); +} + +export function GitHubAppImportSecretFields({ + values, + errors, + update, +}: { + values: GitHubAppImportValues; + errors: AppSetupErrors; + update: Update; +}) { + const labels = { + appId: "App ID", + clientId: "Client ID", + clientSecret: "Client secret", + webhookSecret: "Webhook secret", + } as const; + return ( +
+ {(Object.keys(labels) as (keyof typeof labels)[]).map((name) => ( + + update(name, event.target.value)} + /> + + ))} +
+ +