diff --git a/internal/manager/biz/knowledge/code_browse_test.go b/internal/manager/biz/knowledge/code_browse_test.go index 582f077ad..fd610a441 100644 --- a/internal/manager/biz/knowledge/code_browse_test.go +++ b/internal/manager/biz/knowledge/code_browse_test.go @@ -40,6 +40,20 @@ func (f *fakeRepoStore) UpdateSSHIdentity(context.Context, uint64, string, strin } func (f *fakeRepoStore) TouchSSHIdentityUsage(context.Context, uint64) error { return nil } func (f *fakeRepoStore) DeleteSSHIdentity(context.Context, uint64) error { return nil } +func (f *fakeRepoStore) ListHTTPSCredentials(context.Context) ([]*model.HTTPSCredential, error) { + return nil, nil +} +func (f *fakeRepoStore) GetHTTPSCredential(context.Context, uint64) (*model.HTTPSCredential, error) { + return nil, errs.ErrNotFound +} +func (f *fakeRepoStore) CreateHTTPSCredential(context.Context, *model.HTTPSCredential) error { + return nil +} +func (f *fakeRepoStore) UpdateHTTPSCredential(context.Context, uint64, string, string, string, *string) error { + return nil +} +func (f *fakeRepoStore) TouchHTTPSCredentialUsage(context.Context, uint64) error { return nil } +func (f *fakeRepoStore) DeleteHTTPSCredential(context.Context, uint64) error { return nil } // newCodeBrowseUC builds a Usecase whose repo #1 clone is a real git repo at // cloneDir/1 seeded with sample files. Returns the uc + repo URL. diff --git a/internal/manager/biz/knowledge/https_credential.go b/internal/manager/biz/knowledge/https_credential.go new file mode 100644 index 000000000..63529be14 --- /dev/null +++ b/internal/manager/biz/knowledge/https_credential.go @@ -0,0 +1,229 @@ +// https_credential.go — phase 01-02. +// +// HTTPSCredential is the HTTPS PAT (Personal Access Token) authentication +// counterpart to SSHIdentity. Each row stores one username + token pair +// for the set of hosts it covers. The Sync() path in usecase.go consults +// pickHTTPSCredentialForHost at clone time to build a temporary GIT_ASKPASS +// script that feeds credentials to git — the standard HTTPS authentication +// mechanism for private repositories. +// +// What this file owns: +// - Credential DTOs (CreateHTTPSCredential / UpdateHTTPSCredential inputs) +// - CRUD usecase methods (List, Create, Update, Delete) +// - host pattern matching (exact priority → glob fallback via filepath.Match) +// - extractHTTPSHost: parse host from an HTTP/HTTPS git URL +// +// Security invariants enforced here: +// - T-02-01: List/Create/Update responses always clear Token and set HasToken. +// Plaintext tokens never leave the biz boundary (except pickHTTPSCredentialForHost +// which calls u.repo.ListHTTPSCredentials directly to access tokens for injection). +// - T-02-02: host matching goes through extractHTTPSHost (net/url canonical parse) +// then exact-first / glob-fallback, preventing spoofing via similar domain names. +// - T-02-03: UpdateHTTPSCredential uses *string token semantics — nil means "do not +// change the stored token", preventing accidental token erasure on empty PATCH. +package knowledge + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "path/filepath" + "strings" + "time" + + model "github.com/ongridio/ongrid/internal/manager/model/knowledge" + "github.com/ongridio/ongrid/internal/pkg/errs" +) + +// CreateHTTPSCredentialInput is the create-form payload for an HTTPS +// credential. Token must be non-empty at creation time (CRED-01). Username +// defaults to "oauth2" if empty (GitLab PAT convention). +type CreateHTTPSCredentialInput struct { + Name string + Hosts []string + Username string + Token string +} + +// UpdateHTTPSCredentialInput edits the credential-mutable fields (name, +// host patterns, username, token). Token="" means "do not change the stored +// token"; Token!="" means "rotate to this new value" (CRED-03). +type UpdateHTTPSCredentialInput struct { + Name string + Hosts []string + Username string + Token string +} + +// ListHTTPSCredentials returns every credential sorted by name. Tokens are +// scrubbed from the returned rows; HasToken reflects whether a token is set. +func (u *Usecase) ListHTTPSCredentials(ctx context.Context) ([]*model.HTTPSCredential, error) { + rows, err := u.repo.ListHTTPSCredentials(ctx) + if err != nil { + return nil, err + } + for _, r := range rows { + r.HasToken = r.Token != "" + r.Token = "" + } + return rows, nil +} + +// CreateHTTPSCredential validates + persists a new credential. Returns the +// row with the token scrubbed and HasToken set. +func (u *Usecase) CreateHTTPSCredential(ctx context.Context, in CreateHTTPSCredentialInput) (*model.HTTPSCredential, error) { + name := strings.TrimSpace(in.Name) + if name == "" { + return nil, fmt.Errorf("%w: name required", errs.ErrInvalid) + } + if len(name) > 128 { + return nil, fmt.Errorf("%w: name too long (max 128)", errs.ErrInvalid) + } + + hosts := normalizeHosts(in.Hosts) + if len(hosts) == 0 { + return nil, fmt.Errorf("%w: hosts required (at least one host pattern)", errs.ErrInvalid) + } + + token := strings.TrimSpace(in.Token) + if token == "" { + return nil, fmt.Errorf("%w: token required", errs.ErrInvalid) + } + + username := strings.TrimSpace(in.Username) + if username == "" { + username = "oauth2" + } + + hostsJSON, err := json.Marshal(hosts) + if err != nil { + return nil, fmt.Errorf("encode hosts: %w", err) + } + + now := time.Now().UTC() + row := &model.HTTPSCredential{ + Name: name, + HostsJSON: string(hostsJSON), + Username: username, + Token: token, + CreatedAt: now, + UpdatedAt: now, + } + if err := u.repo.CreateHTTPSCredential(ctx, row); err != nil { + return nil, err + } + row.HasToken = true + row.Token = "" + return row, nil +} + +// UpdateHTTPSCredential edits name / hosts / username / token. If Token is +// empty the stored token is left unchanged; if non-empty the stored token is +// replaced (rotation semantics). Returns the updated row with token scrubbed. +func (u *Usecase) UpdateHTTPSCredential(ctx context.Context, id uint64, in UpdateHTTPSCredentialInput) (*model.HTTPSCredential, error) { + name := strings.TrimSpace(in.Name) + if name == "" { + return nil, fmt.Errorf("%w: name required", errs.ErrInvalid) + } + + hosts := normalizeHosts(in.Hosts) + if len(hosts) == 0 { + return nil, fmt.Errorf("%w: hosts required (at least one host pattern)", errs.ErrInvalid) + } + + hostsJSON, err := json.Marshal(hosts) + if err != nil { + return nil, fmt.Errorf("encode hosts: %w", err) + } + + username := strings.TrimSpace(in.Username) + if username == "" { + username = "oauth2" + } + + // token="" → nil (do not touch stored token); token!="" → &token (rotate). + token := strings.TrimSpace(in.Token) + var tokPtr *string + if token != "" { + tokPtr = &token + } + + if err := u.repo.UpdateHTTPSCredential(ctx, id, name, string(hostsJSON), username, tokPtr); err != nil { + return nil, err + } + + row, err := u.repo.GetHTTPSCredential(ctx, id) + if err != nil { + return nil, err + } + row.HasToken = row.Token != "" + row.Token = "" + return row, nil +} + +// DeleteHTTPSCredential removes the credential by id. +func (u *Usecase) DeleteHTTPSCredential(ctx context.Context, id uint64) error { + return u.repo.DeleteHTTPSCredential(ctx, id) +} + +// pickHTTPSCredentialForHost picks the HTTPS credential to use for the given +// host. Matching order: +// 1. Exact host present in a credential's Hosts list +// 2. Glob match (filepath.Match — same semantics as SSH Identity matching) +// 3. nil → no credential; anonymous HTTPS (public repos still work) +// +// The match is order-stable: credentials are sorted by name in +// ListHTTPSCredentials, so two credentials both glob-matching "git.acme.*" +// always resolve to the same one for the same host. +// +// IMPORTANT: This method calls u.repo.ListHTTPSCredentials directly (not the +// public u.ListHTTPSCredentials) so that the returned rows contain the real +// token value — required for GIT_ASKPASS injection. Tokens never leave this +// function to callers except through the askpass script mechanism. +func (u *Usecase) pickHTTPSCredentialForHost(ctx context.Context, host string) (*model.HTTPSCredential, error) { + rows, err := u.repo.ListHTTPSCredentials(ctx) + if err != nil { + return nil, err + } + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" { + return nil, nil + } + // Pass 1 — exact match. + for _, r := range rows { + for _, pat := range parseHosts(r.HostsJSON) { + if strings.EqualFold(pat, host) { + return r, nil + } + } + } + // Pass 2 — glob match. + for _, r := range rows { + for _, pat := range parseHosts(r.HostsJSON) { + if strings.ContainsAny(pat, "*?[") { + ok, _ := filepath.Match(pat, host) + if ok { + return r, nil + } + } + } + } + return nil, nil +} + +// extractHTTPSHost parses the host out of an HTTP or HTTPS git URL using +// net/url for canonical parsing. Returns the lowercase hostname; returns "" +// if the URL is not HTTP/HTTPS or cannot be parsed. +func extractHTTPSHost(repoURL string) string { + repoURL = strings.TrimSpace(repoURL) + u, err := url.Parse(repoURL) + if err != nil { + return "" + } + scheme := strings.ToLower(u.Scheme) + if scheme != "http" && scheme != "https" { + return "" + } + return strings.ToLower(u.Hostname()) +} diff --git a/internal/manager/biz/knowledge/https_credential_e2e_test.go b/internal/manager/biz/knowledge/https_credential_e2e_test.go new file mode 100644 index 000000000..648519004 --- /dev/null +++ b/internal/manager/biz/knowledge/https_credential_e2e_test.go @@ -0,0 +1,221 @@ +// https_credential_e2e_test.go — phase 01-05 Task 1. +// +// End-to-end smoke test for HTTPS private-repo clone via GIT_ASKPASS injection. +// This test is skipped unless the following environment variables are set: +// +// ONGRID_TEST_HTTPS_REPO — full HTTPS URL of a real private git repo, +// e.g. https://git.example.com/xxx/private.git +// ONGRID_TEST_HTTPS_TOKEN — a valid Personal Access Token for that repo +// +// When both vars are set, the test: +// 1. Builds a fake RepoStore that returns a single HTTPSCredential for the +// repo's host (using the env token). +// 2. Calls buildGitAuthEnv to get the GIT_ASKPASS env slice + cleanup func. +// 3. Runs "git clone --depth=1 /checkout" via runGit. +// 4. Asserts clone succeeded, checkout dir is non-empty, cleanup deleted the +// askpass tempfile, and the token does not appear in any error string. +// +// Security invariants verified: +// - Token never leaks into error/output strings returned by runGit (T-05-03) +// - GIT_ASKPASS tempfile is deleted by cleanup() after clone (T-05-02) +package knowledge + +import ( + "context" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + model "github.com/ongridio/ongrid/internal/manager/model/knowledge" +) + +// --------------------------------------------------------------------------- +// E2E stub store — returns one HTTPSCredential for the target host +// --------------------------------------------------------------------------- + +// e2eCredStore is a minimal RepoStore that serves one hard-wired +// HTTPSCredential for pickHTTPSCredentialForHost and records whether +// TouchHTTPSCredentialUsage was called. All other methods panic. +type e2eCredStore struct { + cred *model.HTTPSCredential + touchCalled bool +} + +func (s *e2eCredStore) ListHTTPSCredentials(_ context.Context) ([]*model.HTTPSCredential, error) { + if s.cred == nil { + return nil, nil + } + return []*model.HTTPSCredential{s.cred}, nil +} + +func (s *e2eCredStore) TouchHTTPSCredentialUsage(_ context.Context, _ uint64) error { + s.touchCalled = true + return nil +} + +// --- Remaining RepoStore methods (not exercised by this test) --------------- + +func (s *e2eCredStore) ListRepos(_ context.Context) ([]*model.Repository, error) { + return nil, nil +} +func (s *e2eCredStore) GetRepo(_ context.Context, _ uint64) (*model.Repository, error) { + panic("not implemented in e2eCredStore") +} +func (s *e2eCredStore) GetRepoByURL(_ context.Context, _ string) (*model.Repository, error) { + panic("not implemented in e2eCredStore") +} +func (s *e2eCredStore) CreateRepo(_ context.Context, _ *model.Repository) error { + panic("not implemented in e2eCredStore") +} +func (s *e2eCredStore) UpdateRepoSync(_ context.Context, _ uint64, _ int, _ string) error { + panic("not implemented in e2eCredStore") +} +func (s *e2eCredStore) DeleteRepo(_ context.Context, _ uint64) error { + panic("not implemented in e2eCredStore") +} +func (s *e2eCredStore) ListSSHIdentities(_ context.Context) ([]*model.SSHIdentity, error) { + return nil, nil +} +func (s *e2eCredStore) GetSSHIdentity(_ context.Context, _ uint64) (*model.SSHIdentity, error) { + panic("not implemented in e2eCredStore") +} +func (s *e2eCredStore) CreateSSHIdentity(_ context.Context, _ *model.SSHIdentity) error { + panic("not implemented in e2eCredStore") +} +func (s *e2eCredStore) UpdateSSHIdentity(_ context.Context, _ uint64, _, _, _ string) error { + panic("not implemented in e2eCredStore") +} +func (s *e2eCredStore) TouchSSHIdentityUsage(_ context.Context, _ uint64) error { + return nil +} +func (s *e2eCredStore) DeleteSSHIdentity(_ context.Context, _ uint64) error { + panic("not implemented in e2eCredStore") +} +func (s *e2eCredStore) GetHTTPSCredential(_ context.Context, _ uint64) (*model.HTTPSCredential, error) { + panic("not implemented in e2eCredStore") +} +func (s *e2eCredStore) CreateHTTPSCredential(_ context.Context, _ *model.HTTPSCredential) error { + panic("not implemented in e2eCredStore") +} +func (s *e2eCredStore) UpdateHTTPSCredential(_ context.Context, _ uint64, _, _, _ string, _ *string) error { + panic("not implemented in e2eCredStore") +} +func (s *e2eCredStore) DeleteHTTPSCredential(_ context.Context, _ uint64) error { + panic("not implemented in e2eCredStore") +} + +// --------------------------------------------------------------------------- +// TestHTTPSE2E_ClonePrivateRepo — main e2e smoke test +// --------------------------------------------------------------------------- + +// TestHTTPSE2E_ClonePrivateRepo performs an end-to-end validation of the full +// HTTPS credential injection chain: +// +// POST /v1/knowledge/https-credentials (model tier, in-memory here) +// → pickHTTPSCredentialForHost +// → buildGitAuthEnv / buildHTTPSEnv (GIT_ASKPASS script) +// → git clone --depth=1 +// +// The test only runs when both env vars are present; otherwise it skips. +// This keeps regular "go test ./..." CI-safe and network-free. +func TestHTTPSE2E_ClonePrivateRepo(t *testing.T) { + repoURL := os.Getenv("ONGRID_TEST_HTTPS_REPO") + token := os.Getenv("ONGRID_TEST_HTTPS_TOKEN") + if repoURL == "" || token == "" { + t.Skip("set ONGRID_TEST_HTTPS_REPO + ONGRID_TEST_HTTPS_TOKEN to run") + } + + // Parse host from the repo URL. + parsed, err := url.Parse(repoURL) + if err != nil { + t.Fatalf("invalid ONGRID_TEST_HTTPS_REPO URL %q: %v", repoURL, err) + } + host := strings.ToLower(parsed.Hostname()) + if host == "" { + t.Fatalf("could not extract host from ONGRID_TEST_HTTPS_REPO=%q", repoURL) + } + + // Build a fake store with one credential covering the target host. + store := &e2eCredStore{ + cred: &model.HTTPSCredential{ + ID: 1, + Name: "e2e-test", + HostsJSON: `["` + host + `"]`, + Username: "oauth2", + Token: token, + }, + } + u := &Usecase{repo: store} + ctx := context.Background() + + // Step 1: Build git auth env via the full credential injection chain. + gitEnv, cleanup, err := u.buildGitAuthEnv(ctx, repoURL) + if err != nil { + // Token must not appear in the error message (T-05-03). + if strings.Contains(err.Error(), token) { + t.Errorf("token leaked into buildGitAuthEnv error: %v", err) + } + t.Fatalf("buildGitAuthEnv failed: %v", err) + } + if len(gitEnv) == 0 { + t.Fatal("buildGitAuthEnv returned empty env for a matched credential; GIT_ASKPASS not set") + } + + // Record the askpass temp file path before cleanup so we can assert deletion. + var askpassPath string + for _, e := range gitEnv { + if strings.HasPrefix(e, "GIT_ASKPASS=") { + askpassPath = strings.TrimPrefix(e, "GIT_ASKPASS=") + break + } + } + if askpassPath == "" { + t.Fatal("GIT_ASKPASS not found in gitEnv") + } + + // Step 2: Clone the private repo with --depth=1 into a temp directory. + tmpDir := t.TempDir() + checkoutDir := filepath.Join(tmpDir, "checkout") + + out, cloneErr := runGit(ctx, "", gitEnv, "clone", "--depth=1", repoURL, checkoutDir) + + // Step 3: Run cleanup BEFORE any t.Fatal so it always executes. + cleanup() + + // Step 4: Assert clone succeeded. + if cloneErr != nil { + // Token must not appear in any git output or error text (T-05-03). + if strings.Contains(out, token) { + t.Errorf("token leaked into git output (redacted for safety)") + } + t.Fatalf("git clone failed: %v\noutput: %s", cloneErr, out) + } + + // Token must not appear in git output even on success. + if strings.Contains(out, token) { + t.Errorf("token leaked into git clone output (T-05-03): output contains token value") + } + + // Step 5: Assert checkout directory is non-empty (proves clone wrote files). + entries, err := os.ReadDir(checkoutDir) + if err != nil { + t.Fatalf("ReadDir checkout: %v", err) + } + if len(entries) == 0 { + t.Error("checkout directory is empty after clone; expected at least one file/directory") + } + + // Step 6: Assert askpass tempfile was deleted by cleanup() (T-05-02). + if _, statErr := os.Stat(askpassPath); !os.IsNotExist(statErr) { + t.Errorf("GIT_ASKPASS tempfile %q still exists after cleanup(); expected deletion (T-05-02)", askpassPath) + } + + // Step 7: Assert TouchHTTPSCredentialUsage was called (usage tracking). + if !store.touchCalled { + t.Error("TouchHTTPSCredentialUsage was not called; usage tracking is broken") + } + + t.Logf("E2E clone SUCCESS: %d entries in checkout, askpass cleaned up, token not leaked", len(entries)) +} diff --git a/internal/manager/biz/knowledge/https_env_test.go b/internal/manager/biz/knowledge/https_env_test.go new file mode 100644 index 000000000..fbb2a1c06 --- /dev/null +++ b/internal/manager/biz/knowledge/https_env_test.go @@ -0,0 +1,324 @@ +// https_env_test.go — phase 01-04 Task 1 tests for buildHTTPSEnv and +// buildGitAuthEnv HTTPS branch. +// +// Security invariants tested: +// - Token value never appears in script body (T-04-01: no argv / file leak) +// - askpass script reads the token from $GIT_PASSWORD at runtime +// - Temp file permissions are owner-only (0o700: rwx------) (T-04-02) +// - cleanup() deletes the temp file (T-04-02) +package knowledge + +import ( + "context" + "os" + "strings" + "testing" + + model "github.com/ongridio/ongrid/internal/manager/model/knowledge" +) + +// --------------------------------------------------------------------------- +// buildHTTPSEnv tests +// --------------------------------------------------------------------------- + +// TestBuildHTTPSEnv_EnvKeys verifies that buildHTTPSEnv returns the three +// required git environment variable keys. +func TestBuildHTTPSEnv_EnvKeys(t *testing.T) { + cred := &model.HTTPSCredential{ + Username: "oauth2", + Token: "glpat-supersecret", + } + env, cleanup, err := buildHTTPSEnv(cred) + if err != nil { + t.Fatalf("buildHTTPSEnv returned error: %v", err) + } + defer cleanup() + + wantKeys := []string{"GIT_ASKPASS=", "GIT_USERNAME=", "GIT_PASSWORD="} + for _, key := range wantKeys { + found := false + for _, e := range env { + if strings.HasPrefix(e, key) { + found = true + break + } + } + if !found { + t.Errorf("env missing key %q; got %v", key, env) + } + } +} + +// TestBuildHTTPSEnv_AskpassPermissions verifies that the GIT_ASKPASS script +// is created with owner-only permissions (0o700: rwx------). git executes +// GIT_ASKPASS directly via execve; without the execute bit git reports +// "permission denied" and authentication fails. +func TestBuildHTTPSEnv_AskpassPermissions(t *testing.T) { + cred := &model.HTTPSCredential{ + Username: "oauth2", + Token: "glpat-supersecret", + } + env, cleanup, err := buildHTTPSEnv(cred) + if err != nil { + t.Fatalf("buildHTTPSEnv returned error: %v", err) + } + defer cleanup() + + // Extract script path from GIT_ASKPASS env entry. + var scriptPath string + for _, e := range env { + if strings.HasPrefix(e, "GIT_ASKPASS=") { + scriptPath = strings.TrimPrefix(e, "GIT_ASKPASS=") + break + } + } + if scriptPath == "" { + t.Fatal("GIT_ASKPASS not found in env") + } + + info, err := os.Stat(scriptPath) + if err != nil { + t.Fatalf("stat askpass script: %v", err) + } + perm := info.Mode().Perm() + if perm != 0o700 { + t.Errorf("askpass script permissions = %04o; want 0700 (owner rwx, group/other=0)", perm) + } +} + +// TestBuildHTTPSEnv_TokenNotInScriptContent verifies the T-04-01 invariant: +// the token value is never written into the script body. The script must read +// the token from the $GIT_PASSWORD environment variable at runtime. +func TestBuildHTTPSEnv_TokenNotInScriptContent(t *testing.T) { + const secretToken = "glpat-toomanysecrets" + cred := &model.HTTPSCredential{ + Username: "oauth2", + Token: secretToken, + } + env, cleanup, err := buildHTTPSEnv(cred) + if err != nil { + t.Fatalf("buildHTTPSEnv returned error: %v", err) + } + defer cleanup() + + var scriptPath string + for _, e := range env { + if strings.HasPrefix(e, "GIT_ASKPASS=") { + scriptPath = strings.TrimPrefix(e, "GIT_ASKPASS=") + break + } + } + + content, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("read askpass script: %v", err) + } + if strings.Contains(string(content), secretToken) { + t.Errorf("token value %q found in askpass script body — token must be read from $GIT_PASSWORD at runtime, not hardcoded", secretToken) + } + // Script must reference $GIT_PASSWORD so git can get the token at runtime. + if !strings.Contains(string(content), "GIT_PASSWORD") { + t.Errorf("askpass script does not reference GIT_PASSWORD; script body:\n%s", content) + } +} + +// TestBuildHTTPSEnv_CleanupDeletesFile verifies that calling cleanup() removes +// the askpass temporary script (T-04-02). +func TestBuildHTTPSEnv_CleanupDeletesFile(t *testing.T) { + cred := &model.HTTPSCredential{ + Username: "oauth2", + Token: "glpat-supersecret", + } + env, cleanup, err := buildHTTPSEnv(cred) + if err != nil { + t.Fatalf("buildHTTPSEnv returned error: %v", err) + } + + var scriptPath string + for _, e := range env { + if strings.HasPrefix(e, "GIT_ASKPASS=") { + scriptPath = strings.TrimPrefix(e, "GIT_ASKPASS=") + break + } + } + + // Verify file exists before cleanup. + if _, err := os.Stat(scriptPath); err != nil { + t.Fatalf("askpass script should exist before cleanup: %v", err) + } + + cleanup() + + // Verify file is gone after cleanup. + if _, err := os.Stat(scriptPath); !os.IsNotExist(err) { + t.Errorf("askpass script should be removed by cleanup(); stat returned: %v", err) + } +} + +// --------------------------------------------------------------------------- +// buildGitAuthEnv HTTPS branch tests +// --------------------------------------------------------------------------- + +// TestBuildGitAuthEnv_HTTPSWithCredential verifies that buildGitAuthEnv +// returns a non-empty env when the repo has a matching HTTPS credential. +func TestBuildGitAuthEnv_HTTPSWithCredential(t *testing.T) { + cred := &model.HTTPSCredential{ + ID: 1, + Username: "oauth2", + Token: "glpat-supersecret", + HostsJSON: `["git.example.com"]`, + } + stub := &httpsTestRepo{creds: []*model.HTTPSCredential{cred}} + u := &Usecase{repo: stub} + + env, cleanup, err := u.buildGitAuthEnv(context.Background(), "https://git.example.com/foo/bar.git") + if err != nil { + t.Fatalf("buildGitAuthEnv returned error: %v", err) + } + defer cleanup() + + if len(env) == 0 { + t.Error("buildGitAuthEnv should return non-empty env for matching HTTPS credential") + } + + hasAskpass := false + for _, e := range env { + if strings.HasPrefix(e, "GIT_ASKPASS=") { + hasAskpass = true + } + } + if !hasAskpass { + t.Errorf("env missing GIT_ASKPASS; got %v", env) + } + + // Verify TouchHTTPSCredentialUsage was called. + if !stub.touchCalled { + t.Error("TouchHTTPSCredentialUsage should have been called on credential match") + } +} + +// TestBuildGitAuthEnv_HTTPSNoCredential verifies that buildGitAuthEnv +// returns nil env + noop cleanup when no credential matches the host +// (anonymous mode — public repos still work). +func TestBuildGitAuthEnv_HTTPSNoCredential(t *testing.T) { + stub := &httpsTestRepo{creds: []*model.HTTPSCredential{}} + u := &Usecase{repo: stub} + + env, cleanup, err := u.buildGitAuthEnv(context.Background(), "https://github.com/public/repo.git") + if err != nil { + t.Fatalf("buildGitAuthEnv returned unexpected error: %v", err) + } + if env != nil { + t.Errorf("buildGitAuthEnv should return nil env for unmatched HTTPS host; got %v", env) + } + // cleanup must be safe to call (noop). + cleanup() +} + +// --------------------------------------------------------------------------- +// Stub RepoStore implementation +// --------------------------------------------------------------------------- + +// httpsTestRepo is a minimal RepoStore stub that satisfies only the methods +// exercised by buildGitAuthEnv / pickHTTPSCredentialForHost. All other methods +// panic so tests that accidentally call them fail loudly. +type httpsTestRepo struct { + creds []*model.HTTPSCredential + touchCalled bool +} + +// ListHTTPSCredentials returns the pre-loaded credentials. +func (s *httpsTestRepo) ListHTTPSCredentials(_ context.Context) ([]*model.HTTPSCredential, error) { + return s.creds, nil +} + +// TouchHTTPSCredentialUsage records that it was called. +func (s *httpsTestRepo) TouchHTTPSCredentialUsage(_ context.Context, _ uint64) error { + s.touchCalled = true + return nil +} + +// The remaining RepoStore methods are not exercised by these tests. + +func (s *httpsTestRepo) ListRepos(_ context.Context) ([]*model.Repository, error) { + panic("not implemented") +} +func (s *httpsTestRepo) GetRepo(_ context.Context, _ uint64) (*model.Repository, error) { + panic("not implemented") +} +func (s *httpsTestRepo) GetRepoByURL(_ context.Context, _ string) (*model.Repository, error) { + panic("not implemented") +} +func (s *httpsTestRepo) CreateRepo(_ context.Context, _ *model.Repository) error { + panic("not implemented") +} +func (s *httpsTestRepo) UpdateRepoSync(_ context.Context, _ uint64, _ int, _ string) error { + panic("not implemented") +} +func (s *httpsTestRepo) DeleteRepo(_ context.Context, _ uint64) error { + panic("not implemented") +} +func (s *httpsTestRepo) ListSSHIdentities(_ context.Context) ([]*model.SSHIdentity, error) { + return nil, nil +} +func (s *httpsTestRepo) GetSSHIdentity(_ context.Context, _ uint64) (*model.SSHIdentity, error) { + panic("not implemented") +} +func (s *httpsTestRepo) CreateSSHIdentity(_ context.Context, _ *model.SSHIdentity) error { + panic("not implemented") +} +func (s *httpsTestRepo) UpdateSSHIdentity(_ context.Context, _ uint64, _, _, _ string) error { + panic("not implemented") +} +func (s *httpsTestRepo) TouchSSHIdentityUsage(_ context.Context, _ uint64) error { + return nil +} +func (s *httpsTestRepo) DeleteSSHIdentity(_ context.Context, _ uint64) error { + panic("not implemented") +} +func (s *httpsTestRepo) GetHTTPSCredential(_ context.Context, _ uint64) (*model.HTTPSCredential, error) { + panic("not implemented") +} +func (s *httpsTestRepo) CreateHTTPSCredential(_ context.Context, _ *model.HTTPSCredential) error { + panic("not implemented") +} +func (s *httpsTestRepo) UpdateHTTPSCredential(_ context.Context, _ uint64, _, _, _ string, _ *string) error { + panic("not implemented") +} +func (s *httpsTestRepo) DeleteHTTPSCredential(_ context.Context, _ uint64) error { + panic("not implemented") +} + +// TestHTTPSNoCredHint covers the locale-neutral English suffix appended to +// last_sync_error when a private HTTPS clone fails with no credential +// configured for its host (AUTH-04 / Phase 1 SC5). It must name the host, +// stay English, and stay silent for every not-a-missing-cred case. +func TestHTTPSNoCredHint(t *testing.T) { + const authOut = "fatal: could not read Username for 'https://git.example.com': terminal prompts disabled" + + // Missing-credential HTTPS auth failure → hint names the host. + got := httpsNoCredHint("https://git.example.com/team/repo.git", nil, authOut) + if !strings.Contains(got, "host=git.example.com") { + t.Fatalf("expected hint to contain host=git.example.com, got %q", got) + } + if !strings.Contains(got, "no HTTPS credential configured") { + t.Fatalf("expected English no-credential guidance, got %q", got) + } + + // Credential was injected (GIT_ASKPASS present) → no hint. + withCred := []string{"GIT_ASKPASS=/tmp/ongrid-askpass-x.sh", "GIT_PASSWORD=secret"} + if got := httpsNoCredHint("https://git.example.com/team/repo.git", withCred, "authentication failed"); got != "" { + t.Fatalf("expected no hint when credential injected, got %q", got) + } + + // SSH URL → no hint (SSH has its own flow). + if got := httpsNoCredHint("git@git.example.com:team/repo.git", nil, authOut); got != "" { + t.Fatalf("expected no hint for SSH URL, got %q", got) + } + + // Non-auth failure (network) → no hint. + netOut := "fatal: unable to access 'https://git.example.com/team/repo.git': Could not resolve host: git.example.com" + if got := httpsNoCredHint("https://git.example.com/team/repo.git", nil, netOut); got != "" { + t.Fatalf("expected no hint for non-auth failure, got %q", got) + } +} diff --git a/internal/manager/biz/knowledge/usecase.go b/internal/manager/biz/knowledge/usecase.go index 9ad584df8..5d9629132 100644 --- a/internal/manager/biz/knowledge/usecase.go +++ b/internal/manager/biz/knowledge/usecase.go @@ -55,6 +55,15 @@ type RepoStore interface { UpdateSSHIdentity(ctx context.Context, id uint64, name, hostsJSON, knownHosts string) error TouchSSHIdentityUsage(ctx context.Context, id uint64) error DeleteSSHIdentity(ctx context.Context, id uint64) error + + // HTTPS credentials — per-host PAT/username pairs for private HTTPS + // git repos. Mirrors the SSH identity slot in the same layer. + ListHTTPSCredentials(ctx context.Context) ([]*model.HTTPSCredential, error) + GetHTTPSCredential(ctx context.Context, id uint64) (*model.HTTPSCredential, error) + CreateHTTPSCredential(ctx context.Context, c *model.HTTPSCredential) error + UpdateHTTPSCredential(ctx context.Context, id uint64, name, hostsJSON, username string, token *string) error + TouchHTTPSCredentialUsage(ctx context.Context, id uint64) error + DeleteHTTPSCredential(ctx context.Context, id uint64) error } // QdrantClient is the narrow qdrant surface. *qdrantx.Client satisfies @@ -980,7 +989,13 @@ func (u *Usecase) Sync(ctx context.Context, id uint64) (*model.Repository, error // KnowledgeRepos.tsx) so the message follows the UI locale. // Storing a Chinese annotation here made English-mode show // Chinese (the stored string is fixed at sync time). - return u.recordSyncFailure(ctx, repo, fmt.Errorf("git clone failed: %v\n%s", err, strings.TrimSpace(out))) + // + // httpsNoCredHint appends a locale-neutral English suffix + // carrying host= when this is a private HTTPS clone with + // no credential configured (AUTH-04). It stays English so the + // SPA can still localize; last_sync_error now names the host + // the operator must configure. + return u.recordSyncFailure(ctx, repo, fmt.Errorf("git clone failed: %v\n%s%s", err, strings.TrimSpace(out), httpsNoCredHint(repo.URL, gitEnv, out))) } } } @@ -2046,19 +2061,37 @@ func repoChunkPoint(repoID uint64, url string, chunkIndex, chunkTotal int, vec [ // a 0600 temp file and produce a GIT_SSH_COMMAND env line that // pins -i + IdentitiesOnly so the key picked is the only one // tried (avoids accidental fallback to the container's ~/.ssh). -// - anything else (https / http) → no auth env; git tries anonymous. -// Right for public repos like ongridio/vault. Private HTTPS repos -// will fail until P3 wires the credential.helper-based -// per-host token table. +// - anything else (https / http) → look up an HTTPS credential +// matching the URL host; on hit, write a temporary GIT_ASKPASS +// script (owner-only 0o700) that feeds username/token to git. +// No credential match → anonymous (public repos still work). // -// The cleanup func deletes any temp files (keyfile + known_hosts for -// SSH) and is always safe to call, even on the no-auth path. +// The cleanup func deletes any temp files and is always safe to call, +// even on the no-auth path. func (u *Usecase) buildGitAuthEnv(ctx context.Context, repoURL string) ([]string, func(), error) { noop := func() {} if !isSSHURL(repoURL) { - // HTTPS / http → anonymous. P3 will introduce credential.helper. - return nil, noop, nil + // HTTPS / http → look up per-host HTTPS credential. + host := extractHTTPSHost(repoURL) + if host == "" { + return nil, noop, nil + } + cred, err := u.pickHTTPSCredentialForHost(ctx, host) + if err != nil { + return nil, noop, fmt.Errorf("https credential lookup: %w", err) + } + if cred == nil { + // No credential matched → anonymous; public repos still work. + return nil, noop, nil + } + env, cleanup, err := buildHTTPSEnv(cred) + if err != nil { + return nil, noop, err + } + // Best-effort usage timestamp; never fails the clone. + _ = u.repo.TouchHTTPSCredentialUsage(ctx, cred.ID) + return env, cleanup, nil } host := extractSSHHost(repoURL) @@ -2082,6 +2115,61 @@ func (u *Usecase) buildGitAuthEnv(ctx context.Context, repoURL string) ([]string return env, cleanup, nil } +// buildHTTPSEnv writes a temporary GIT_ASKPASS shell script that feeds +// username/token to git for HTTPS private-repo authentication. The script +// reads the token from the $GIT_PASSWORD environment variable at runtime — +// the token is never embedded in the script body (T-04-01: prevents leakage +// via script read-access or ps/cmdline inspection). +// +// Permission decision (LOCKED): the script is set to 0o700 (owner rwx, +// group/other=0). git executes GIT_ASKPASS directly via execve — the binary +// must have the owner execute bit. A pure 0o600 (no execute bit) would cause +// git to report "permission denied" and fail authentication. 0o700 retains +// the "no other user can read or write the token channel" isolation intent +// while satisfying git's execve requirement. +// +// Returns env (GIT_ASKPASS + GIT_USERNAME + GIT_PASSWORD) + cleanup that +// removes the temp script. +func buildHTTPSEnv(cred *model.HTTPSCredential) ([]string, func(), error) { + noop := func() {} + + // The askpass script reads the token from $GIT_PASSWORD at runtime. + // It does NOT contain the token value — see T-04-01. + const scriptBody = "#!/bin/sh\ncase \"$1\" in\n *Username*) printf '%s' \"$GIT_USERNAME\" ;;\n *) printf '%s' \"$GIT_PASSWORD\" ;;\nesac\n" + + f, err := os.CreateTemp("", "ongrid-askpass-*.sh") + if err != nil { + return nil, noop, fmt.Errorf("write askpass tempfile: %w", err) + } + if _, err := f.WriteString(scriptBody); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return nil, noop, fmt.Errorf("write askpass script: %w", err) + } + if err := f.Close(); err != nil { + _ = os.Remove(f.Name()) + return nil, noop, fmt.Errorf("close askpass script: %w", err) + } + // 0o700: owner rwx, group/other=0. git execve-s GIT_ASKPASS directly; + // execute bit is required. See doc comment above for full rationale. + if err := os.Chmod(f.Name(), 0o700); err != nil { + _ = os.Remove(f.Name()) + return nil, noop, fmt.Errorf("chmod askpass script: %w", err) + } + + cleanup := func() { _ = os.Remove(f.Name()) } + + // Token goes into GIT_PASSWORD env var only — never into argv or the + // script body (T-04-01). GIT_USERNAME is also passed via env so the + // askpass script can echo it without hardcoding. + env := []string{ + "GIT_ASKPASS=" + f.Name(), + "GIT_USERNAME=" + cred.Username, + "GIT_PASSWORD=" + cred.Token, + } + return env, cleanup, nil +} + // buildSSHEnv writes the identity's private key + known_hosts to 0600 // temp files and assembles a GIT_SSH_COMMAND env line that points git // at them. Returns the env + a cleanup func that removes both files. @@ -2188,9 +2276,12 @@ func annotateGitError(gitOutput, repoURL string, hasAuth bool) string { // HTTPS / generic auth signatures. case strings.Contains(low, "could not read username") || (strings.Contains(low, "authentication failed") && !hasAuth): - return fmt.Sprintf("私库需要凭证,但当前未配置 host=%s 的 token。请到「代码仓库 → 凭证」配置。原始输出:%s", host, gitOutput) + // No credential configured for this host — direct the operator to + // set up an HTTPS credential (AUTH-04). + return fmt.Sprintf("私库需要凭证,但当前未为 host=%s 配置 HTTPS 凭证。请到「代码仓库 → HTTPS 凭证」添加一条 hosts 包含 %s 的凭证。原始输出:%s", host, host, gitOutput) case strings.Contains(low, "authentication failed") && hasAuth: - return fmt.Sprintf("host=%s 拒绝了已配置的凭证。请检查:(1) token 未过期 (2) scope 充足 (3) 对该仓库有访问权。原始输出:%s", host, gitOutput) + // Credential was injected but rejected — token may be expired / revoked. + return fmt.Sprintf("host=%s 拒绝了已配置的 HTTPS 凭证。请检查:(1) token 未过期 (2) scope 充足(需 read_repository 权限)(3) 对该仓库有访问权。原始输出:%s", host, gitOutput) case strings.Contains(low, "repository not found"): return fmt.Sprintf("找不到该仓库。检查 URL 拼写(大小写敏感);若是私库,确认凭证对该 host=%s 有访问权。原始输出:%s", host, gitOutput) case strings.Contains(low, "rate limit") || strings.Contains(low, "api rate limit exceeded"): @@ -2210,6 +2301,35 @@ func annotateGitError(gitOutput, repoURL string, hasAuth bool) string { } } +// httpsNoCredHint returns a locale-neutral English suffix to append to the +// raw git error stored in last_sync_error when a private HTTPS clone failed +// with no credential configured for its host. Kept English (NOT localized) +// so the SPA's gitErrorHint can still classify + localize per UI locale, +// while last_sync_error carries the specific host the operator must +// configure (AUTH-04 / Phase 1 SC5). Returns "" when this is not a +// missing-HTTPS-credential case: an SSH URL, a run where a credential was +// already injected (GIT_ASKPASS present), or a non-auth failure. +func httpsNoCredHint(repoURL string, gitEnv []string, gitOutput string) string { + if isSSHURL(repoURL) { + return "" + } + for _, e := range gitEnv { + if strings.HasPrefix(e, "GIT_ASKPASS=") { + return "" // a credential was injected — this is not a missing-cred case + } + } + low := strings.ToLower(gitOutput) + if !strings.Contains(low, "could not read username") && + !strings.Contains(low, "authentication failed") { + return "" + } + host := extractHTTPSHost(repoURL) + if host == "" { + host = extractDisplayHost(repoURL) + } + return fmt.Sprintf("\nno HTTPS credential configured for host=%s; add one under Knowledge > HTTPS credentials", host) +} + // extractDisplayHost pulls the host name out of a git URL for display // purposes. Returns "" on unparseable input — the caller's fmt template // then just shows "host=" which is uglier than perfect but acceptable. diff --git a/internal/manager/data/knowledge/store/repo.go b/internal/manager/data/knowledge/store/repo.go index f3e72ab32..27dcec947 100644 --- a/internal/manager/data/knowledge/store/repo.go +++ b/internal/manager/data/knowledge/store/repo.go @@ -14,11 +14,11 @@ import ( "github.com/ongridio/ongrid/internal/pkg/errs" ) -// Migrate registers knowledge_repos + ssh_identities. +// Migrate registers knowledge_repos + ssh_identities + https_credentials. // knowledge_docs is no longer created — Phase-2 moved doc storage to // qdrant. Idempotent. func Migrate(db *gorm.DB) error { - return db.AutoMigrate(&model.Repository{}, &model.SSHIdentity{}) + return db.AutoMigrate(&model.Repository{}, &model.SSHIdentity{}, &model.HTTPSCredential{}) } // Repo is the relational repo (git repo registrations only). @@ -166,3 +166,77 @@ func (r *Repo) DeleteSSHIdentity(ctx context.Context, id uint64) error { } return nil } + +// ----- https_credentials ---------------------------------------- + +// ListHTTPSCredentials returns every stored HTTPS credential, name-asc. +func (r *Repo) ListHTTPSCredentials(ctx context.Context) ([]*model.HTTPSCredential, error) { + var out []*model.HTTPSCredential + if err := r.db.WithContext(ctx).Order("name ASC").Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +// GetHTTPSCredential fetches one by id; ErrNotFound on miss. +func (r *Repo) GetHTTPSCredential(ctx context.Context, id uint64) (*model.HTTPSCredential, error) { + var out model.HTTPSCredential + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&out).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.ErrNotFound + } + return nil, err + } + return &out, nil +} + +// CreateHTTPSCredential persists a new HTTPS credential. Caller is +// responsible for validating hosts JSON and ensuring token is non-empty +// before calling. +func (r *Repo) CreateHTTPSCredential(ctx context.Context, cred *model.HTTPSCredential) error { + return r.db.WithContext(ctx).Create(cred).Error +} + +// UpdateHTTPSCredential updates the editable fields (name / hosts / +// username / token). Token uses pointer semantics for rotate-on-change: +// token==nil means "do not touch the stored token"; token!=nil means +// "overwrite with this new value". RowsAffected==0 returns ErrNotFound. +func (r *Repo) UpdateHTTPSCredential(ctx context.Context, id uint64, name, hostsJSON, username string, token *string) error { + updates := map[string]any{ + "name": name, + "hosts": hostsJSON, + "username": username, + } + if token != nil { + updates["token"] = *token + } + res := r.db.WithContext(ctx).Model(&model.HTTPSCredential{}).Where("id = ?", id). + Updates(updates) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} + +// TouchHTTPSCredentialUsage bumps last_used_at after a successful clone. +// Best-effort; errors are logged at the biz layer but don't fail the +// clone — the timestamp is purely operational. +func (r *Repo) TouchHTTPSCredentialUsage(ctx context.Context, id uint64) error { + return r.db.WithContext(ctx).Model(&model.HTTPSCredential{}).Where("id = ?", id). + Update("last_used_at", gorm.Expr("CURRENT_TIMESTAMP")).Error +} + +// DeleteHTTPSCredential removes by id; ErrNotFound on miss. +func (r *Repo) DeleteHTTPSCredential(ctx context.Context, id uint64) error { + res := r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.HTTPSCredential{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} diff --git a/internal/manager/model/knowledge/model.go b/internal/manager/model/knowledge/model.go index cd8cf9751..d670ef95a 100644 --- a/internal/manager/model/knowledge/model.go +++ b/internal/manager/model/knowledge/model.go @@ -87,6 +87,42 @@ type SSHIdentity struct { // TableName pins the table name. func (SSHIdentity) TableName() string { return "ssh_identities" } +// HTTPSCredential is a stored username + Personal Access Token used to +// authenticate HTTPS git clones. One row per "logical credential" (e.g. +// one for git.example.com, a separate one for git.acme.com, etc.). +// +// Lookup at clone time: pickHTTPSCredential(parsedHost) → match host +// against `HostsJSON` (JSON array, supports glob like "git.acme.*"); +// on hit a temporary GIT_ASKPASS script is materialised with the +// username / token and injected into the git subprocess env. +// +// Token is sensitive: it is stored plaintext (consistent with +// ssh_identities.private_key) but MUST NOT be returned in GET/List +// responses. Callers use the non-persistent HasToken field instead. +// +// MySQL TEXT columns cannot carry a DEFAULT clause (Error 1101) — +// so Token has no DB-level default; biz layer always supplies the value +// on insert and uses *string / nil semantics on update (nil = do not +// overwrite existing token, non-nil = rotate to new value). +type HTTPSCredential struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"size:128;not null;uniqueIndex:uk_https_cred_name"` + HostsJSON string `gorm:"type:text;not null;column:hosts"` // JSON array of host glob patterns + Username string `gorm:"size:128;not null;default:oauth2"` + Token string `gorm:"type:text;column:token"` // sensitive; no DB default (MySQL TEXT restriction) + LastUsedAt *time.Time `gorm:"column:last_used_at"` + CreatedAt time.Time + UpdatedAt time.Time + + // HasToken is NOT persisted (gorm:"-"). It is set by the biz layer to + // indicate whether a token is currently configured, so handlers can + // return has_token:true/false without leaking the plaintext value. + HasToken bool `gorm:"-"` +} + +// TableName pins the table name. +func (HTTPSCredential) TableName() string { return "https_credentials" } + // Doc is one indexable document. Manual ones are user-pasted markdown; // repo ones are markdown / config / code files imported from a synced // repository. The canonical store is qdrant (vector search). MySQL diff --git a/internal/manager/server/knowledge/http.go b/internal/manager/server/knowledge/http.go index eedd5293f..fca5c971a 100644 --- a/internal/manager/server/knowledge/http.go +++ b/internal/manager/server/knowledge/http.go @@ -69,6 +69,12 @@ type Service interface { GenerateSSHIdentity(ctx context.Context, in biz.GenerateSSHIdentityInput) (*model.SSHIdentity, error) UpdateSSHIdentity(ctx context.Context, id uint64, in biz.UpdateSSHIdentityInput) (*model.SSHIdentity, error) DeleteSSHIdentity(ctx context.Context, id uint64) error + + // HTTPS credentials. + ListHTTPSCredentials(ctx context.Context) ([]*model.HTTPSCredential, error) + CreateHTTPSCredential(ctx context.Context, in biz.CreateHTTPSCredentialInput) (*model.HTTPSCredential, error) + UpdateHTTPSCredential(ctx context.Context, id uint64, in biz.UpdateHTTPSCredentialInput) (*model.HTTPSCredential, error) + DeleteHTTPSCredential(ctx context.Context, id uint64) error } // AuthzMW is the narrow casbin middleware contract. Optional — when @@ -134,6 +140,12 @@ func (h *Handler) Register(r chi.Router) { r.With(h.writeMW("knowledge:repo")).Post("/v1/knowledge/ssh-identities/generate", h.generateSSHIdentity) r.With(h.writeMW("knowledge:repo")).Patch("/v1/knowledge/ssh-identities/{id}", h.updateSSHIdentity) r.With(h.deleteMW("knowledge:repo")).Delete("/v1/knowledge/ssh-identities/{id}", h.deleteSSHIdentity) + // HTTPS credentials — PAT-based auth for private HTTPS repos. + // No /generate endpoint: PATs are generated on the provider side (GitLab/GitHub). + r.Get("/v1/knowledge/https-credentials", h.listHTTPSCredentials) + r.With(h.writeMW("knowledge:repo")).Post("/v1/knowledge/https-credentials", h.createHTTPSCredential) + r.With(h.writeMW("knowledge:repo")).Patch("/v1/knowledge/https-credentials/{id}", h.updateHTTPSCredential) + r.With(h.deleteMW("knowledge:repo")).Delete("/v1/knowledge/https-credentials/{id}", h.deleteHTTPSCredential) } // --- DTOs --- diff --git a/internal/manager/server/knowledge/https_credential.go b/internal/manager/server/knowledge/https_credential.go new file mode 100644 index 000000000..c8ef48a1d --- /dev/null +++ b/internal/manager/server/knowledge/https_credential.go @@ -0,0 +1,135 @@ +// https_credential.go — HTTP layer for /v1/knowledge/https-credentials +// +// Security: DTO never contains plaintext token; only HasToken (bool) is +// returned. This satisfies T-03-01 (Information Disclosure) at the REST +// boundary, complementing the biz-layer scrubbing in plan 02. +package knowledge + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + biz "github.com/ongridio/ongrid/internal/manager/biz/knowledge" + model "github.com/ongridio/ongrid/internal/manager/model/knowledge" + "github.com/ongridio/ongrid/internal/pkg/errs" +) + +// httpsCredentialDTO is the public view of a stored HTTPS credential. +// Token is intentionally absent — callers receive only has_token (bool) +// indicating whether a token is configured. This prevents plaintext PAT +// leakage through the API surface (T-03-01). +type httpsCredentialDTO struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Hosts []string `json:"hosts"` + Username string `json:"username"` + HasToken bool `json:"has_token"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func toHTTPSCredentialDTO(row *model.HTTPSCredential) httpsCredentialDTO { + hosts := []string{} + if row.HostsJSON != "" { + _ = json.Unmarshal([]byte(row.HostsJSON), &hosts) + } + return httpsCredentialDTO{ + ID: row.ID, + Name: row.Name, + Hosts: hosts, + Username: row.Username, + HasToken: row.HasToken, + LastUsedAt: row.LastUsedAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +// createHTTPSCredentialReq is the POST /v1/knowledge/https-credentials body. +type createHTTPSCredentialReq struct { + Name string `json:"name"` + Hosts []string `json:"hosts"` + Username string `json:"username"` + Token string `json:"token"` +} + +// updateHTTPSCredentialReq is the PATCH /v1/knowledge/https-credentials/{id} body. +// Token="" means "do not change stored token"; Token!="" rotates to the new value. +type updateHTTPSCredentialReq struct { + Name string `json:"name"` + Hosts []string `json:"hosts"` + Username string `json:"username"` + Token string `json:"token"` +} + +func (h *Handler) listHTTPSCredentials(w http.ResponseWriter, r *http.Request) { + rows, err := h.svc.ListHTTPSCredentials(r.Context()) + if err != nil { + writeErr(w, err) + return + } + out := make([]httpsCredentialDTO, 0, len(rows)) + for _, row := range rows { + out = append(out, toHTTPSCredentialDTO(row)) + } + writeJSON(w, http.StatusOK, map[string]any{"items": out, "total": len(out)}) +} + +func (h *Handler) createHTTPSCredential(w http.ResponseWriter, r *http.Request) { + var req createHTTPSCredentialReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, errors.Join(errs.ErrInvalid, err)) + return + } + row, err := h.svc.CreateHTTPSCredential(r.Context(), biz.CreateHTTPSCredentialInput{ + Name: req.Name, + Hosts: req.Hosts, + Username: req.Username, + Token: req.Token, + }) + if err != nil { + writeErr(w, err) + return + } + writeJSON(w, http.StatusCreated, toHTTPSCredentialDTO(row)) +} + +func (h *Handler) updateHTTPSCredential(w http.ResponseWriter, r *http.Request) { + id, err := parseUintParam(r, "id") + if err != nil { + writeErr(w, err) + return + } + var req updateHTTPSCredentialReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, errors.Join(errs.ErrInvalid, err)) + return + } + row, err := h.svc.UpdateHTTPSCredential(r.Context(), id, biz.UpdateHTTPSCredentialInput{ + Name: req.Name, + Hosts: req.Hosts, + Username: req.Username, + Token: req.Token, + }) + if err != nil { + writeErr(w, err) + return + } + writeJSON(w, http.StatusOK, toHTTPSCredentialDTO(row)) +} + +func (h *Handler) deleteHTTPSCredential(w http.ResponseWriter, r *http.Request) { + id, err := parseUintParam(r, "id") + if err != nil { + writeErr(w, err) + return + } + if err := h.svc.DeleteHTTPSCredential(r.Context(), id); err != nil { + writeErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/web/src/api/knowledge.ts b/web/src/api/knowledge.ts index affe0d1d3..e7f46e67d 100644 --- a/web/src/api/knowledge.ts +++ b/web/src/api/knowledge.ts @@ -246,6 +246,51 @@ export function deleteSSHIdentity(id: number) { return request('DELETE', `/knowledge/ssh-identities/${id}`); } +// ----- HTTPS credentials API ----- +// +// One row = one stored HTTPS PAT credential + the host patterns it +// covers + the username (e.g. "oauth2" for GitLab PAT). The token is +// write-only; after creation/update the API only surfaces has_token +// (bool). No token field in the type — token 只写不读,对齐后端 DTO. + +export type HTTPSCredential = { + id: number; + name: string; + hosts: string[]; // host glob patterns + username: string; // e.g. "oauth2" + has_token: boolean; // true = token is configured + last_used_at?: string | null; + created_at: string; + updated_at: string; + // Note: no `token` field — token is write-only; never echoed back by the API. +}; + +export function listHTTPSCredentials() { + return request<{ items: HTTPSCredential[]; total: number }>('GET', '/knowledge/https-credentials'); +} + +export function createHTTPSCredential(input: { + name: string; + hosts: string[]; + username: string; + token: string; +}) { + return request('POST', '/knowledge/https-credentials', input); +} + +export function updateHTTPSCredential(id: number, input: { + name: string; + hosts: string[]; + username: string; + token: string; // empty string = keep existing token (server nil-means-keep semantics) +}) { + return request('PATCH', `/knowledge/https-credentials/${id}`, input); +} + +export function deleteHTTPSCredential(id: number) { + return request('DELETE', `/knowledge/https-credentials/${id}`); +} + // ----- i18n localizer for built-in seed content ----- // // The 38 docs currently in qdrant (seeded 2026-05-09 from the network/ diff --git a/web/src/pages/KnowledgeRepos.tsx b/web/src/pages/KnowledgeRepos.tsx index b8d2bee71..a520afd47 100644 --- a/web/src/pages/KnowledgeRepos.tsx +++ b/web/src/pages/KnowledgeRepos.tsx @@ -18,15 +18,20 @@ import { Modal } from '@/components/Modal'; import { createRepo, createSSHIdentity, + createHTTPSCredential, deleteRepo, deleteSSHIdentity, + deleteHTTPSCredential, generateSSHIdentity, isBuiltinVault, listRepos, listSSHIdentities, + listHTTPSCredentials, syncRepo, + updateHTTPSCredential, type KnowledgeRepo, type SSHIdentity, + type HTTPSCredential, } from '@/api/knowledge'; import { ApiError } from '@/api/client'; import { useI18n } from '@/i18n/locale'; @@ -54,6 +59,19 @@ function gitErrorHint(raw: string, url: string, tr: (zh: string, en: string) => 'SSH host key 不匹配:服务器指纹与已存 known_hosts 不一致(中间人 / DNS 劫持 / 服务器换密钥)。请人工核对。', 'SSH host key mismatch: the server fingerprint differs from the stored known_hosts (MITM / DNS hijack / server rekey). Verify manually.', ); + if (low.includes('no https credential configured for host=')) { + const m = raw.match(/no HTTPS credential configured for host=([^;\s]*)/i); + const host = m?.[1] ?? ''; + return host + ? tr( + `私有仓库需要凭证:尚未为 host=${host} 配置 HTTPS 凭证。请在上方『凭证 · HTTPS』卡片添加一条 hosts 匹配的凭证。`, + `This private repo needs a credential: no HTTPS credential configured for host=${host}. Add one with a matching host in the "Credentials · HTTPS" card above.`, + ) + : tr( + '私有仓库需要凭证:尚未为该 host 配置 HTTPS 凭证。请在上方『凭证 · HTTPS』卡片添加一条 hosts 匹配的凭证。', + 'This private repo needs a credential: no HTTPS credential configured for this host. Add one with a matching host in the "Credentials · HTTPS" card above.', + ); + } if (low.includes('could not read username') || low.includes('authentication failed')) return tr( '凭证缺失或被拒:私有仓库需要 token / 凭证,或已配置的凭证无访问权。请在凭证里配置。', @@ -182,6 +200,8 @@ export default function KnowledgeReposPage() { )} + + {loading ? ( @@ -376,6 +396,11 @@ function RepoCreator({ onClose, onCreated }: { onClose: () => void; onCreated: ( 'HTTPS(公开仓库)或 SSH(git@host:owner/repo)都行。SSH 私库需要先在上方"凭证 · SSH key"配一条 hosts 匹配的 key。不要把 token 嵌进 URL —— 会被 git argv / 日志 / DB 列泄漏。', 'HTTPS (public) or SSH (git@host:owner/repo) both work. For SSH private repos, configure a matching SSH key in "Credentials · SSH key" above first. Do NOT embed tokens in the URL — they leak via git argv / logs / DB columns.', )} + {' '} + {tr( + 'HTTPS 私库需先在上方『凭证 · HTTPS』配一条 hosts 匹配的凭证;同样不要把 token 嵌进 URL。', + 'For private HTTPS repos, configure a matching credential in "Credentials · HTTPS" above first; likewise never embed the token in the URL.', + )}