f814859ab2
Managed GitHub credentials compared a repository's scope identity verbatim across sides that spell it differently. A repository cloned over SSH stores an SSH remote_url, which kandev itself backfills for local-path repositories, and the strict scheme assertion added in #2117 then rejected the launch with "must use an HTTPS clone URL for managed credentials". Credential identity now comes from the persisted provider identity, whose host carries the real HTTPS origin including a non-default port, falling back to rewriting a non-HTTPS remote in place. Issuance and authorization derive it the same way, so they cannot disagree. Cloning is untouched. Scope paths are compared through one canonical form (no trailing slash, no ".git") at the shim, the broker redemption, and the backend authorizer, so the suffix never decides whether a lease matches. Lease scopes still carry the exact path their provider resolver was issued for, because plugin providers compare it verbatim. Closes #2673 Closes #2675
130 lines
5.3 KiB
Go
130 lines
5.3 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/kandev/kandev/internal/gitcredentials"
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
func gitCredentialScopeTestRepository(remoteURL string) *fakeGitHubBrokerTaskRepository {
|
|
repository := gitCredentialScopeRepository(remoteURL)
|
|
repository.ProviderOwner = "acme"
|
|
repository.ProviderName = "widgets"
|
|
return &fakeGitHubBrokerTaskRepository{
|
|
task: &taskmodels.Task{ID: "task-1", WorkspaceID: "workspace-1"},
|
|
session: &taskmodels.TaskSession{ID: "session-1", TaskID: "task-1", State: taskmodels.TaskSessionStateRunning},
|
|
repository: repository,
|
|
links: []*taskmodels.TaskRepository{{TaskID: "task-1", RepositoryID: "repository-1"}},
|
|
}
|
|
}
|
|
|
|
func gitCredentialScopeRepository(remoteURL string) *taskmodels.Repository {
|
|
return &taskmodels.Repository{
|
|
ID: "repository-1", WorkspaceID: "workspace-1", Provider: "github", RemoteURL: remoteURL,
|
|
}
|
|
}
|
|
|
|
func gitCredentialScopeTaskRepository(repository *taskmodels.Repository) *fakeGitHubBrokerTaskRepository {
|
|
return &fakeGitHubBrokerTaskRepository{
|
|
task: &taskmodels.Task{ID: "task-1", WorkspaceID: "workspace-1"},
|
|
session: &taskmodels.TaskSession{ID: "session-1", TaskID: "task-1", State: taskmodels.TaskSessionStateRunning},
|
|
repository: repository,
|
|
links: []*taskmodels.TaskRepository{{TaskID: "task-1", RepositoryID: "repository-1"}},
|
|
}
|
|
}
|
|
|
|
func gitCredentialScopeForPath(path string) gitcredentials.Scope {
|
|
return gitCredentialScopeForHostPath("github.com", path)
|
|
}
|
|
|
|
func gitCredentialScopeForHostPath(host, path string) gitcredentials.Scope {
|
|
return gitcredentials.Scope{
|
|
ProviderID: "github", WorkspaceID: "workspace-1", TaskID: "task-1", SessionID: "session-1",
|
|
RepositoryID: "repository-1", Host: host, Path: path,
|
|
}
|
|
}
|
|
|
|
// The executor derives the lease scope from the repository's clone URL, so the
|
|
// authorizer must recognize the same repository however that column spells it:
|
|
// SSH or HTTPS, with or without the ".git" suffix the broker canonicalizes away.
|
|
func TestGitHubBrokerScopeAuthorizerAcceptsEquivalentRepositorySpellings(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
name string
|
|
remoteURL string
|
|
scopePath string
|
|
}{
|
|
{name: "scp style SSH remote", remoteURL: "git@github.com:acme/widgets.git", scopePath: "/acme/widgets"},
|
|
{name: "ssh scheme remote", remoteURL: "ssh://git@github.com/acme/widgets.git", scopePath: "/acme/widgets"},
|
|
{name: "https remote canonical scope", remoteURL: "https://github.com/acme/widgets.git", scopePath: "/acme/widgets"},
|
|
{name: "https remote suffixed scope", remoteURL: "https://github.com/acme/widgets.git", scopePath: "/acme/widgets.git"},
|
|
{name: "derived from provider columns", remoteURL: "", scopePath: "/acme/widgets"},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
authorizer := &githubBrokerScopeAuthorizer{repo: gitCredentialScopeTestRepository(testCase.remoteURL)}
|
|
|
|
if err := authorizer.AuthorizeGitCredential(
|
|
context.Background(), gitCredentialScopeForPath(testCase.scopePath),
|
|
); err != nil {
|
|
t.Fatalf("AuthorizeGitCredential() error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGitHubBrokerScopeAuthorizerRejectsForeignRepositoryScope(t *testing.T) {
|
|
for name, testCase := range map[string]struct {
|
|
repository *taskmodels.Repository
|
|
scopePath string
|
|
}{
|
|
"different repository": {
|
|
repository: gitCredentialScopeTestRepository("git@github.com:acme/widgets.git").repository,
|
|
scopePath: "/acme/widgets-staging",
|
|
},
|
|
"ssh remote on another host, no provider identity": {
|
|
repository: gitCredentialScopeRepository("git@github.example:acme/widgets.git"),
|
|
scopePath: "/acme/widgets",
|
|
},
|
|
"unsupported remote, no provider identity": {
|
|
repository: gitCredentialScopeRepository("file:///tmp/widgets.git"),
|
|
scopePath: "/acme/widgets",
|
|
},
|
|
"https remote on another host wins over provider identity": {
|
|
repository: gitCredentialScopeTestRepository("https://github.example/acme/widgets.git").repository,
|
|
scopePath: "/acme/widgets",
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
authorizer := &githubBrokerScopeAuthorizer{repo: gitCredentialScopeTaskRepository(testCase.repository)}
|
|
|
|
if err := authorizer.AuthorizeGitCredential(
|
|
context.Background(), gitCredentialScopeForPath(testCase.scopePath),
|
|
); err == nil {
|
|
t.Fatal("AuthorizeGitCredential() error = nil, want scope denial")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// An SSH URL cannot express the provider's HTTPS port, so the persisted
|
|
// provider identity, not a rewrite of the remote, decides the origin.
|
|
func TestGitHubBrokerScopeAuthorizerUsesProviderHostPortForSSHRemote(t *testing.T) {
|
|
repository := gitCredentialScopeRepository("ssh://git@ghe.example:2222/acme/widgets.git")
|
|
repository.ProviderHost = "https://ghe.example:8443"
|
|
repository.ProviderOwner = "acme"
|
|
repository.ProviderName = "widgets"
|
|
authorizer := &githubBrokerScopeAuthorizer{repo: gitCredentialScopeTaskRepository(repository)}
|
|
|
|
if err := authorizer.AuthorizeGitCredential(
|
|
context.Background(), gitCredentialScopeForHostPath("ghe.example:8443", "/acme/widgets"),
|
|
); err != nil {
|
|
t.Fatalf("AuthorizeGitCredential() error = %v", err)
|
|
}
|
|
if err := authorizer.AuthorizeGitCredential(
|
|
context.Background(), gitCredentialScopeForHostPath("ghe.example", "/acme/widgets"),
|
|
); err == nil {
|
|
t.Fatal("AuthorizeGitCredential() error = nil, want portless origin denial")
|
|
}
|
|
}
|