diff --git a/.claude/skills/development/SKILL.md b/.claude/skills/development/SKILL.md index b871b1d3..b0d4a81b 100644 --- a/.claude/skills/development/SKILL.md +++ b/.claude/skills/development/SKILL.md @@ -87,8 +87,8 @@ git log -- client/client.go client.go # a path-limited log from client/ alone git blame client/client.go # traverses the merge on its own ``` -**`client/` does not log in.** `client.Authorization` produces a bearer token and replaces one that -came back 401; resolving a credential, minting a token, caching it and refreshing it is `pkg/auth`. +**`client/` does not log in.** `client.Authorization` produces a credential and replaces one that came +back 401; resolving a credential, minting a token, caching it and refreshing it is `pkg/auth`. Both front ends build their client through `auth.ResolveClient`, so the endpoint and the authorization always agree with what was resolved. Do **not** add a login exchange anywhere else: a second one gets a static token and starts returning 401 once it expires, and for a browser login it diff --git a/client/client.go b/client/client.go index 36e1b13b..e29f4b35 100644 --- a/client/client.go +++ b/client/client.go @@ -44,6 +44,7 @@ type Client struct { WorkspaceGroupBinding MeshWorkspaceGroupBindingClient WorkspaceUserBinding MeshWorkspaceUserBindingClient Raw *RawClient + Runner MeshRunnerClient // Endpoint is read by the Terraform provider's meshstack_instance data source. Endpoint xurl.URL @@ -90,6 +91,7 @@ func New(ctx context.Context, endpoint xurl.URL, client http.Client, auth Author with(buildingBlockDefinition.meshObject). withOwnOrder(buildingBlockDefinitionVersion.meshObject). with(newEventLogMeshObject(ctx, authorizedClient)), + Runner: newRunnerClient(authorizedClient), Endpoint: endpoint, } diff --git a/client/runner.go b/client/runner.go new file mode 100644 index 00000000..74a79270 --- /dev/null +++ b/client/runner.go @@ -0,0 +1,362 @@ +package client + +import ( + "context" + "encoding/json/jsontext" + "errors" + "fmt" + "io" + gohttp "net/http" + "net/url" + "strings" + "time" + "uuid" + + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/json" +) + +type MeshRunnerRun struct { + ApiVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata MeshRunnerRunMetadata `json:"metadata"` + Spec MeshRunnerRunSpec `json:"spec"` + Status string `json:"status"` + Links MeshRunnerRunLinks `json:"_links"` +} + +type MeshRunnerRunMetadata struct { + Uuid uuid.UUID `json:"uuid"` +} + +type MeshRunnerRunSpec struct { + RunNumber int `json:"runNumber"` + Behavior string `json:"behavior"` + BuildingBlock MeshRunnerRunBuildingBlock `json:"buildingBlock"` + Definition MeshRunnerRunDefinition `json:"buildingBlockDefinition"` + RunToken string `json:"runToken"` + Trigger *MeshRunnerRunTrigger `json:"trigger,omitzero"` +} + +type MeshRunnerRunBuildingBlock struct { + Uuid string `json:"uuid"` + Spec MeshRunnerRunBuildingBlockSpec `json:"spec"` +} + +type MeshRunnerRunBuildingBlockSpec struct { + DisplayName string `json:"displayName"` + WorkspaceIdentifier string `json:"workspaceIdentifier"` + ProjectIdentifier string `json:"projectIdentifier,omitempty"` + FullPlatformIdentifier string `json:"fullPlatformIdentifier,omitempty"` + Inputs []MeshRunnerRunInput `json:"inputs"` + ParentBuildingBlocks []MeshRunnerRunParentBlock `json:"parentBuildingBlocks"` +} + +type MeshRunnerRunParentBlock struct { + BuildingBlockUuid string `json:"buildingBlockUuid"` + DefinitionUuid string `json:"definitionUuid"` +} + +type MeshRunnerRunInput struct { + Key string `json:"key"` + // Value stays raw JSON, so that an INTEGER beyond float64 or int64 keeps every digit. + Value jsontext.Value `json:"value"` + Type string `json:"type"` + IsSensitive bool `json:"isSensitive"` + Env bool `json:"isEnvironment"` +} + +type MeshRunnerRunDefinition struct { + Uuid string `json:"uuid"` + Spec MeshRunnerRunDefinitionSpec `json:"spec"` +} + +type MeshRunnerRunDefinitionSpec struct { + WorkspaceIdentifier string `json:"workspaceIdentifier"` + Version int `json:"version"` + Implementation jsontext.Value `json:"implementation"` +} + +func (d MeshRunnerRunDefinitionSpec) ImplementationType() (MeshRunnerImplementationType, error) { + var implementation struct { + Type MeshRunnerImplementationType `json:"type"` + } + if err := json.Unmarshal(d.Implementation, &implementation); err != nil { + return "", fmt.Errorf("cannot read the implementation type: %w", err) + } + return implementation.Type, nil +} + +type MeshRunnerRunTrigger struct { + Reason string `json:"reason"` + Author MeshRunnerRunAuthor `json:"author"` +} + +type MeshRunnerRunAuthor struct { + Identifier string `json:"identifier"` + Type string `json:"type"` + DisplayName *string `json:"displayName"` + Username *string `json:"username"` + Email *string `json:"email"` + Euid *string `json:"euid"` +} + +type MeshRunnerRunLinks struct { + Self MeshRunnerRunLink `json:"self"` + RegisterSource MeshRunnerRunLink `json:"registerSource"` + UpdateSource MeshRunnerRunLink `json:"updateSource"` + MeshstackBaseUrl MeshRunnerRunLink `json:"meshstackBaseUrl"` + // PlanArtifact is set only when an APPLY run applies the plan of its DETECT run. + PlanArtifact MeshRunnerRunLink `json:"planArtifact,omitzero"` + ArtifactUpload MeshRunnerRunLink `json:"artifactUpload,omitzero"` + // TfStateLock is empty on a meshStack that cannot lock the state. + TfStateLock MeshRunnerRunLink `json:"tfStateLock,omitzero"` +} + +type MeshRunnerRunLink struct { + Href string `json:"href"` + Templated bool `json:"templated,omitzero"` +} + +func (l MeshRunnerRunLink) parse(name string) (*url.URL, error) { + if l.Href == "" { + return nil, fmt.Errorf("the run has no %s link", name) + } + return url.Parse(l.Href) +} + +type MeshRunnerImplementationType string + +const ( + MeshRunnerImplementationTypeManual MeshRunnerImplementationType = "MANUAL" + MeshRunnerImplementationTypeTerraform MeshRunnerImplementationType = "TERRAFORM" + MeshRunnerImplementationTypeGithubWorkflow MeshRunnerImplementationType = "GITHUB_WORKFLOW" + MeshRunnerImplementationTypeGitlabCiCd MeshRunnerImplementationType = "GITLAB_CICD" + MeshRunnerImplementationTypeAzureDevops MeshRunnerImplementationType = "AZURE_DEVOPS" +) + +type MeshRunnerTerraformImplementation struct { + Type string `json:"type"` + TerraformVersion string `json:"terraformVersion"` + RepositoryUrl string `json:"repositoryUrl"` + RepositoryPath *string `json:"repositoryPath,omitzero"` + RefName *string `json:"refName,omitzero"` + SshPrivateKey *string `json:"sshPrivateKey,omitzero"` + KnownHost *MeshRunnerKnownHost `json:"knownHost,omitzero"` + Async bool `json:"async"` + UseMeshHttpBackendFallback bool `json:"useMeshHttpBackendFallback"` + PreRunScript *string `json:"preRunScript,omitzero"` +} + +type MeshRunnerKnownHost struct { + Host string `json:"host"` + KeyType string `json:"keyType"` + KeyValue string `json:"keyValue"` +} + +type MeshRunnerGithubImplementation struct { + Type string `json:"type"` + GithubBaseUrl string `json:"githubBaseUrl"` + Owner string `json:"owner"` + AppId string `json:"appId"` + AppPem string `json:"appPem"` + Repository string `json:"repository"` + Branch string `json:"branch"` + ApplyWorkflow string `json:"applyWorkflow"` + DestroyWorkflow *string `json:"destroyWorkflow,omitzero"` + Async bool `json:"async"` + OmitRunObjectInput bool `json:"omitRunObjectInput"` +} + +type MeshRunnerGitlabImplementation struct { + Type string `json:"type"` + GitlabBaseUrl string `json:"gitlabBaseUrl"` + ProjectId string `json:"projectId"` + RefName string `json:"refName"` + PipelineTriggerToken string `json:"pipelineTriggerToken"` +} + +type MeshRunnerAzureDevopsImplementation struct { + Type string `json:"type"` + AzureDevOpsBaseUrl string `json:"azureDevOpsBaseUrl"` + Organization string `json:"organization"` + Project string `json:"project"` + PipelineId string `json:"pipelineId"` + PersonalAccessToken string `json:"personalAccessToken"` + Async bool `json:"async"` + RefName *string `json:"refName,omitzero"` +} + +type MeshRunnerRunSourceRegistration struct { + Source MeshRunnerRunSource `json:"source"` + Steps []MeshRunnerRunStepRegistration `json:"steps"` +} + +type MeshRunnerRunSource struct { + Id uuid.UUID `json:"id"` + ExternalId *string `json:"externalId,omitzero"` + ExternalUrl *string `json:"externalUrl,omitzero"` +} + +type MeshRunnerRunStepRegistration struct { + Id string `json:"id"` + DisplayName string `json:"displayName"` + Status *string `json:"status"` +} + +type MeshRunnerRunStatusUpdate struct { + BlockRunId uuid.UUID `json:"blockRunId,omitzero"` + Source uuid.UUID `json:"source,omitzero"` + Type MeshRunnerImplementationType `json:"type,omitempty"` + Status *string `json:"status"` + CreatedOn time.Time `json:"createdOn,omitzero"` + Summary *string `json:"summary"` + Steps []MeshRunnerRunStepStatusUpdate `json:"steps"` + ChangesDetected *bool `json:"changesDetected,omitzero"` +} + +type MeshRunnerRunStepStatusUpdate struct { + Id string `json:"id"` + DisplayName string `json:"displayName"` + Status *string `json:"status,omitzero"` + UserMessage *string `json:"userMessage,omitzero"` + SystemMessage *string `json:"systemMessage,omitzero"` + Outputs map[string]MeshRunnerRunOutput `json:"outputs,omitempty"` +} + +type MeshRunnerRunOutput struct { + Value any `json:"value"` + Type string `json:"type"` + Sensitive bool `json:"isSensitive"` +} + +type MeshRunnerRunStatusUpdateResult struct { + Abort bool `json:"runAborted"` +} + +const ( + runMediaType = "application/vnd.meshcloud.api.meshbuildingblockrun.v1.hal+json" + + maxArtifactBytes = 128 << 20 +) + +var ErrNoRunToClaim = errors.New("no run to claim") + +type MeshRunnerClient interface { + ClaimRun(ctx context.Context, runnerUuid uuid.UUID) (MeshRunnerRun, error) + // ForRun authorizes with the run's token, which every request about the run needs. + ForRun(run MeshRunnerRun) MeshRunnerRunClient +} + +type MeshRunnerRunClient interface { + RegisterSource(ctx context.Context, registration MeshRunnerRunSourceRegistration) error + PatchStatus(ctx context.Context, sourceId uuid.UUID, update MeshRunnerRunStatusUpdate) (MeshRunnerRunStatusUpdateResult, error) + DownloadPlanArtifact(ctx context.Context, w io.Writer) error + UploadArtifact(ctx context.Context, path string) error +} + +func newRunnerClient(httpClient internal.HttpClient) MeshRunnerClient { + return meshRunnerClient{runnerHttpClient{httpClient.AuthorizedClient}, httpClient.EndpointUrl} +} + +type meshRunnerClient struct { + http runnerHttpClient + endpoint xurl.URL +} + +func (c meshRunnerClient) ClaimRun(ctx context.Context, runnerUuid uuid.UUID) (MeshRunnerRun, error) { + claimUrl := c.endpoint.JoinPath("api", "meshobjects", "meshbuildingblockruns", "create") + claimUrl.RawQuery = url.Values{"forRunnerUuid": {runnerUuid.String()}}.Encode() + run, err := c.http.do[MeshRunnerRun](ctx, http.MethodPost, claimUrl, http.WithHeaders(gohttp.Header{ + "Accept": {runMediaType}, + "Content-Type": {runMediaType}, + })) + if httpErr, ok := errors.AsType[http.Error](err); ok && httpErr.IsNotFound() { + return run, ErrNoRunToClaim + } + if err == nil && run.Metadata.Uuid == (uuid.UUID{}) { + err = errors.New("the claimed run has no uuid") + } + return run, err +} + +func (c meshRunnerClient) ForRun(run MeshRunnerRun) MeshRunnerRunClient { + runToken := http.BearerToken(run.Spec.RunToken) + return meshRunnerRunClient{runnerHttpClient{c.http.WithAuthorization(runToken)}, run.Links} +} + +type meshRunnerRunClient struct { + http runnerHttpClient + links MeshRunnerRunLinks +} + +func (c meshRunnerRunClient) RegisterSource(ctx context.Context, registration MeshRunnerRunSourceRegistration) error { + registerUrl, err := c.links.RegisterSource.parse("registerSource") + if err != nil { + return err + } + _, err = c.http.do[[]byte](ctx, http.MethodPost, registerUrl, http.WithJsonPayload(registration, runMediaType), http.Retryable()) + if httpErr, ok := errors.AsType[http.Error](err); ok && httpErr.IsConflict() { + return nil + } + return err +} + +func (c meshRunnerRunClient) PatchStatus(ctx context.Context, sourceId uuid.UUID, update MeshRunnerRunStatusUpdate) (MeshRunnerRunStatusUpdateResult, error) { + updateSource := MeshRunnerRunLink{Href: strings.ReplaceAll(c.links.UpdateSource.Href, "{sourceId}", sourceId.String())} + updateUrl, err := updateSource.parse("updateSource") + if err != nil { + return MeshRunnerRunStatusUpdateResult{}, err + } + return c.http.do[MeshRunnerRunStatusUpdateResult](ctx, http.MethodPatch, updateUrl, http.WithJsonPayload(update, runMediaType), http.Retryable()) +} + +func (c meshRunnerRunClient) DownloadPlanArtifact(ctx context.Context, w io.Writer) error { + downloadUrl, err := c.links.PlanArtifact.parse("planArtifact") + if err != nil { + return err + } + capped := &cappedWriter{w, maxArtifactBytes} + return c.http.DoStreamingRequest(ctx, http.MethodGet, downloadUrl, capped, c.http.version(), http.WithAccept("application/octet-stream")) +} + +func (c meshRunnerRunClient) UploadArtifact(ctx context.Context, path string) error { + uploadUrl, err := c.links.ArtifactUpload.parse("artifactUpload") + if err != nil { + return err + } + _, err = c.http.do[[]byte](ctx, http.MethodPut, uploadUrl, http.WithBodyFile(path), http.WithHeaders(gohttp.Header{ + "Accept": {"*/*"}, + "Content-Type": {"application/octet-stream"}, + })) + return err +} + +type runnerHttpClient struct { + http.AuthorizedClient +} + +func (c runnerHttpClient) do[R any](ctx context.Context, method string, url *url.URL, options ...http.RequestOption) (R, error) { + return c.DoRequest[R](ctx, method, url, append(options, c.version())...) +} + +func (c runnerHttpClient) version() http.RequestOption { + return http.WithHeaders(gohttp.Header{"X-Meshcloud-Runner-Version": {c.UserAgent.Version}}) +} + +// cappedWriter keeps a misbehaving server from filling the disk. +type cappedWriter struct { + io.Writer + + remaining int64 +} + +func (w *cappedWriter) Write(p []byte) (int, error) { + if int64(len(p)) > w.remaining { + return 0, fmt.Errorf("the artifact exceeds %d bytes", maxArtifactBytes) + } + w.remaining -= int64(len(p)) + return w.Writer.Write(p) +} diff --git a/client/runner_test.go b/client/runner_test.go new file mode 100644 index 00000000..176b3cda --- /dev/null +++ b/client/runner_test.go @@ -0,0 +1,149 @@ +package client_test + +import ( + "bytes" + "encoding/json/jsontext" + "encoding/json/v2" + "io" + gohttp "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "uuid" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" +) + +func TestRunnerRun(t *testing.T) { + data, readErr := os.ReadFile("testdata/runner_run.json") + require.NoError(t, readErr) + var run client.MeshRunnerRun + require.NoError(t, json.Unmarshal(data, &run)) + + t.Run("round-trips unchanged", func(t *testing.T) { + assert.Equal(t, "12345678901234567890", run.Spec.BuildingBlock.Spec.Inputs[0].Value.String()) + implementationType, err := run.Spec.Definition.Spec.ImplementationType() + require.NoError(t, err) + assert.Equal(t, client.MeshRunnerImplementationTypeTerraform, implementationType) + + encoded, err := json.Marshal(run) + require.NoError(t, err) + assert.JSONEq(t, string(data), string(encoded)) + }) + + t.Run("round-trips unchanged once decrypted", func(t *testing.T) { + var err error + run.Spec.BuildingBlock.Spec.Inputs[3].Value, err = json.Marshal("plaintext") + require.NoError(t, err) + var implementation client.MeshRunnerTerraformImplementation + require.NoError(t, json.Unmarshal(run.Spec.Definition.Spec.Implementation, &implementation)) + implementation.SshPrivateKey = new("-----BEGIN OPENSSH PRIVATE KEY-----") + run.Spec.Definition.Spec.Implementation, err = json.Marshal(implementation) + require.NoError(t, err) + + encoded, err := json.Marshal(run) + require.NoError(t, err) + var decoded client.MeshRunnerRun + require.NoError(t, json.Unmarshal(encoded, &decoded)) + reencoded, err := json.Marshal(decoded) + require.NoError(t, err) + assert.JSONEq(t, string(encoded), string(reencoded)) + assert.JSONEq(t, `"plaintext"`, decoded.Spec.BuildingBlock.Spec.Inputs[3].Value.String()) + }) +} + +func TestRunnerRunClient(t *testing.T) { + t.Run("PatchStatus sends only the fields set, with the run token", func(t *testing.T) { + sourceId := uuid.MustParse("98520496-627d-43e6-82da-ce499179ff3f") + runClient := newRunClient(t, func(w gohttp.ResponseWriter, r *gohttp.Request) { + assert.Equal(t, gohttp.MethodPatch, r.Method) + assert.Equal(t, "/runs/c662eb0d/status/source/"+sourceId.String(), r.URL.Path) + assert.Equal(t, "Bearer a-run-token", r.Header.Get("Authorization")) + assert.Equal(t, "application/vnd.meshcloud.api.meshbuildingblockrun.v1.hal+json", r.Header.Get("Content-Type")) + assert.Equal(t, fakemeshstack.UserAgent.Version, r.Header.Get("X-Meshcloud-Runner-Version")) + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.JSONEq(t, `{ + "status": "SUCCEEDED", + "summary": null, + "steps": [{"id": "manual", "displayName": "Manual Block Run", "status": "SUCCEEDED", "userMessage": "", + "outputs": {"count": {"value": 12345678901234567890, "type": "INTEGER", "isSensitive": false}}}] + }`, string(body)) + _, _ = w.Write([]byte(`{"runAborted": true}`)) + }) + + result, err := runClient.PatchStatus(t.Context(), sourceId, client.MeshRunnerRunStatusUpdate{ + Status: new("SUCCEEDED"), + Steps: []client.MeshRunnerRunStepStatusUpdate{{ + Id: "manual", DisplayName: "Manual Block Run", Status: new("SUCCEEDED"), UserMessage: new(""), + Outputs: map[string]client.MeshRunnerRunOutput{"count": {Value: jsontext.Value("12345678901234567890"), Type: "INTEGER"}}, + }}, + }) + require.NoError(t, err) + assert.True(t, result.Abort) + }) + + t.Run("DownloadPlanArtifact streams up to the cap", func(t *testing.T) { + var size atomic.Int64 + size.Store(1 << 20) + runClient := newRunClient(t, func(w gohttp.ResponseWriter, r *gohttp.Request) { + assert.Equal(t, "/plan", r.URL.Path) + assert.Equal(t, "application/octet-stream", r.Header.Get("Accept")) + _, _ = io.CopyN(w, zeros{}, size.Load()) + }) + + var plan bytes.Buffer + require.NoError(t, runClient.DownloadPlanArtifact(t.Context(), &plan)) + assert.Equal(t, size.Load(), int64(plan.Len())) + + size.Store(128<<20 + 1) + assert.ErrorContains(t, runClient.DownloadPlanArtifact(t.Context(), io.Discard), "the artifact exceeds 134217728 bytes") + }) + + t.Run("UploadArtifact sends the file", func(t *testing.T) { + planFile := filepath.Join(t.TempDir(), "plan.tfplan") + require.NoError(t, os.WriteFile(planFile, []byte("a plan"), 0o600)) + runClient := newRunClient(t, func(w gohttp.ResponseWriter, r *gohttp.Request) { + assert.Equal(t, gohttp.MethodPut, r.Method) + assert.Equal(t, "/upload", r.URL.Path) + assert.Equal(t, "application/octet-stream", r.Header.Get("Content-Type")) + assert.Equal(t, int64(6), r.ContentLength) + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.Equal(t, "a plan", string(body)) + w.WriteHeader(gohttp.StatusNoContent) + }) + + require.NoError(t, runClient.UploadArtifact(t.Context(), planFile)) + }) +} + +type zeros struct{} + +func (zeros) Read(p []byte) (int, error) { + clear(p) + return len(p), nil +} + +func newRunClient(t *testing.T, handler gohttp.HandlerFunc) client.MeshRunnerRunClient { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + c := client.New(t.Context(), xurl.MustParsef("%s", server.URL), http.NewClient(fakemeshstack.UserAgent), http.BasicAuth{Username: "runner", Password: "secret"}) + return c.Runner.ForRun(client.MeshRunnerRun{ + Spec: client.MeshRunnerRunSpec{RunToken: "a-run-token"}, + Links: client.MeshRunnerRunLinks{ + UpdateSource: client.MeshRunnerRunLink{Href: server.URL + "/runs/c662eb0d/status/source/{sourceId}", Templated: true}, + PlanArtifact: client.MeshRunnerRunLink{Href: server.URL + "/plan"}, + ArtifactUpload: client.MeshRunnerRunLink{Href: server.URL + "/upload"}, + }, + }) +} diff --git a/client/testdata/runner_run.json b/client/testdata/runner_run.json new file mode 100644 index 00000000..4701e775 --- /dev/null +++ b/client/testdata/runner_run.json @@ -0,0 +1,55 @@ +{ + "apiVersion": "v1", + "kind": "meshBuildingBlockRun", + "metadata": { + "uuid": "c662eb0d-2ee2-4dc3-92df-d0b8bd41df44" + }, + "spec": { + "runNumber": 1, + "behavior": "APPLY", + "buildingBlock": { + "uuid": "23cf7c1b-f0ba-4139-a98c-b8cea37e83cf", + "spec": { + "displayName": "my-block", + "workspaceIdentifier": "managed-customer", + "inputs": [ + { "key": "count", "value": 12345678901234567890, "type": "INTEGER", "isSensitive": false, "isEnvironment": false }, + { "key": "ratio", "value": 0.5, "type": "NUMBER", "isSensitive": false, "isEnvironment": false }, + { "key": "config", "value": { "nested": [1, 2] }, "type": "CODE", "isSensitive": false, "isEnvironment": false }, + { "key": "password", "value": "ZW5jcnlwdGVk", "type": "STRING", "isSensitive": true, "isEnvironment": true } + ], + "parentBuildingBlocks": [ + { "buildingBlockUuid": "5d1b8a2e-7c1f-4a51-9d0e-6f1f4b7c2a10", "definitionUuid": "0b6d1b5e-3c0f-4a51-9d0e-6f1f4b7c2a10" } + ] + } + }, + "buildingBlockDefinition": { + "uuid": "f3228cf4-acde-4ff3-a0a5-1720b69d5bcd", + "spec": { + "workspaceIdentifier": "test-workspace", + "version": 1, + "implementation": { + "type": "TERRAFORM", + "terraformVersion": "1.9.0", + "repositoryUrl": "git@github.com:meshcloud/modules.git", + "sshPrivateKey": "ZW5jcnlwdGVkLWtleQ==", + "async": false, + "useMeshHttpBackendFallback": true + } + } + }, + "runToken": "a-run-token", + "trigger": { + "reason": "MANUAL_EXECUTION", + "author": { "identifier": "jane", "type": "User", "displayName": "Jane", "username": "jane", "email": null, "euid": null } + } + }, + "status": "IN_PROGRESS", + "_links": { + "self": { "href": "http://localhost:8080/api/meshobjects/meshbuildingblockruns/c662eb0d-2ee2-4dc3-92df-d0b8bd41df44" }, + "registerSource": { "href": "http://localhost:8080/api/meshobjects/meshbuildingblockruns/c662eb0d-2ee2-4dc3-92df-d0b8bd41df44/status/source" }, + "updateSource": { "href": "http://localhost:8080/api/meshobjects/meshbuildingblockruns/c662eb0d-2ee2-4dc3-92df-d0b8bd41df44/status/source/{sourceId}", "templated": true }, + "meshstackBaseUrl": { "href": "http://localhost:8080" }, + "planArtifact": { "href": "http://localhost:8080/api/meshobjects/meshbuildingblockruns/a1b2c3d4-0000-4000-8000-000000000000/artifact" } + } +} diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 832e134e..bab3664b 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -35,6 +35,7 @@ func newLogin(profileSources setting.Sources) *cobra.Command { openStdinFlag = newStdinFlag() apiKeyFlag = internal.NewFlagForSetting[uuid.UUID]("apikey", setting.ApiKeyClientId) apiTokenFlag = internal.NewFlagForSetting[bool]("apitoken", setting.ApiToken) + usernameFlag = internal.NewFlagForSetting[string]("username", setting.ApiUsername) output internal.ShowFlag ) @@ -77,13 +78,22 @@ It ends with what meshstack auth status shows for the new login.`, if !openStdinFlag.Value { opts.SettingSources = append(opts.SettingSources, newWorkspaceSelectionSource(promptedFrom, true)) } + case usernameFlag.Value != "": + authWith = credential.BasicAuthName + opts.SettingSources = append(opts.SettingSources, + usernameFlag.AsSource(), + newPromptingSource(setting.ApiPassword.EnvKey(), &openStdinFlag, promptedFrom, "Password"), + ) + if !openStdinFlag.Value { + opts.SettingSources = append(opts.SettingSources, newWorkspaceSelectionSource(promptedFrom, true)) + } case apiTokenFlag.Value: authWith = credential.ManualName opts.SettingSources = append(opts.SettingSources, newPromptingSource(setting.ApiToken.EnvKey(), &openStdinFlag, promptedFrom, "API Token")) default: if openStdinFlag.Value { - return fmt.Errorf("--%s reads the secret of --%s or --%s, and a browser login has none", - openStdinFlag.Name, apiKeyFlag.Name, apiTokenFlag.Name) + return fmt.Errorf("--%s reads the secret of --%s, --%s or --%s, and a browser login has none", + openStdinFlag.Name, apiKeyFlag.Name, usernameFlag.Name, apiTokenFlag.Name) } authWith = credential.OidcLoginName opts.SettingSources = append(opts.SettingSources, newWorkspaceSelectionSource(promptedFrom, false)) @@ -102,8 +112,7 @@ It ends with what meshstack auth status shows for the new login.`, defer func() { err = errors.Join(err, unlock()) }() - _, err = session.GetBearerToken(ctx) - if err != nil { + if err := session.CheckCredential(ctx); err != nil { return err } if _, err := session.MeshInfo(); err != nil { @@ -119,6 +128,7 @@ It ends with what meshstack auth status shows for the new login.`, cmd.MarkFlagsMutuallyExclusive( apiKeyFlag.Register(cmd.Flags()), apiTokenFlag.Register(cmd.Flags()), + usernameFlag.Register(cmd.Flags()), ) apiKeyOption := cmd.Flags().Lookup(apiKeyFlag.Name.String()) // NoOptDefVal is what makes a bare --apikey, with no value after it, parse. diff --git a/cmd/auth/stdin.go b/cmd/auth/stdin.go index 5e85f9e5..8f8cfff0 100644 --- a/cmd/auth/stdin.go +++ b/cmd/auth/stdin.go @@ -13,7 +13,7 @@ import ( const stdinFlagName internal.FlagName = "stdin" func newStdinFlag() internal.Flag[bool] { - return internal.Flag[bool]{Name: stdinFlagName, Help: "prompt the API key secret or API token from stdin"} + return internal.Flag[bool]{Name: stdinFlagName, Help: "prompt the API key secret, the password or the API token from stdin"} } func newPromptingSource(settingEnvKey string, openStdinFlag *internal.Flag[bool], p prompt.Prompt, what string) setting.FrontendSource { diff --git a/cmd/internal/markdown/credential.md.tmpl b/cmd/internal/markdown/credential.md.tmpl index 2fd5dfde..449e7181 100644 --- a/cmd/internal/markdown/credential.md.tmpl +++ b/cmd/internal/markdown/credential.md.tmpl @@ -11,6 +11,7 @@ {{- $token := .Token}} | Credential | {{.Kind.Label}} {{- with .ApiKey}}{{if eq $.Kind "manual"}} of API key{{end}} {{.ClientId}}{{else}}{{if and $token $token.ClientId}} of API key {{cell $token.ClientId}}{{end}}{{end -}} +{{- with .BasicAuth}} {{cell .Username}}{{end -}} , from {{range $i, $source := .Sources}}{{if $i}}, {{end}}{{cell $source}}{{end}} | {{- with .ApiKey}}{{with .Details}} | | {{cell .DisplayName}}, owned by workspace {{cell .OwnedByWorkspace}}, @@ -26,9 +27,11 @@ {{- with .OidcLogin}} | | {{if .SessionEndsAt.IsZero}}Session with no end known{{else if .SessionEnded}}💤 Session ended {{at .SessionEndsAt}}, run `meshstack login -p {{$.Profile}}`{{else}}Session ends at the latest {{at .SessionEndsAt}}, then run `meshstack login -p {{$.Profile}}`{{end}} | {{- end}} +{{- if not .BasicAuth}} | | {{with $token}}{{if .Expired}}Token expired {{at .ExpiresAt}}, {{if eq $.Kind "manual"}}cannot be renewed, run `meshstack login -p {{$.Profile}} --apitoken`{{else if and $.OidcLogin $.OidcLogin.SessionEnded}}cannot be renewed{{else}}renewed on next use{{end}} {{- else}}Token expires {{at .ExpiresAt}}{{end}}{{with .Workspace}}, for workspace {{cell .}}{{end}} {{- else}}No token cached yet{{if ne .Kind "manual"}}, minted on next use{{end}}{{end}} | +{{- end}} {{- with .ApiKey}}{{with .Details}} {{- range .PermissionGroups}} | | • {{cell .Name}}: {{range $i, $pattern := .Patterns}}{{if $i}}, {{end}}`{{cell $pattern}}`{{end}} | @@ -38,6 +41,7 @@ {{- range $i, $unused := .Unused}} | {{if not $i}}Unused credentials{{end}} | • {{.Kind.Label}} {{- with .ClientId}} {{cell .}}{{end}} +{{- with .Username}} {{cell .}}{{end}} {{- with .Token}}{{if eq $unused.Kind "manual"}}, {{if .Expired}}expired{{else}}expires{{end}} {{at .ExpiresAt}} {{- else}}{{with .User}} {{cell .}}{{end}}{{with .Email}}{{if ne . $unused.Token.User}} ({{cell .}}){{end}}{{end}}{{end}}{{end}} {{- if .Selected}}, selected by the profile{{end}} | diff --git a/cmd/internal/markdown/markdown_test.go b/cmd/internal/markdown/markdown_test.go index 8546e8f3..df555782 100644 --- a/cmd/internal/markdown/markdown_test.go +++ b/cmd/internal/markdown/markdown_test.go @@ -47,6 +47,25 @@ func TestABrowserLoginShowsItsUserAccessAndSessionEnd(t *testing.T) { }) } +func TestBasicAuthShowsItsUsernameAndNoToken(t *testing.T) { + status := auth.CredentialStatus{ + Profile: "runner", Endpoint: xurl.MustParsef("https://meshstack.example.com"), + Kind: credential.BasicAuthName, Sources: []string{"env MESHSTACK_API_USERNAME", "env MESHSTACK_API_PASSWORD"}, + BasicAuth: &auth.BasicAuthStatus{Username: "runner"}, + Unused: []auth.UnusedCredential{{Kind: credential.BasicAuthName, Username: "other", Selected: true}}, + } + + shown, err := Execute(credentialOnly, status) + + require.NoError(t, err) + assert.Equal(t, "| Profile | runner |\n"+ + "| --- | --- |\n"+ + "| Endpoint | https://meshstack.example.com |\n"+ + "| Workspace | none |\n"+ + "| Credential | Basic auth runner, from env MESHSTACK_API_USERNAME, env MESHSTACK_API_PASSWORD |\n"+ + "| Unused credentials | • Basic auth other, selected by the profile |", shown) +} + func TestAnApiKeyShowsItsPermissionsBelowTheCredential(t *testing.T) { synctest.Test(t, func(t *testing.T) { clientId := uuid.MustParse("11111111-45bf-42ba-a965-2097b9d0d181") diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 13bf060c..acc7a732 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -8,6 +8,7 @@ import ( "time" "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/internal/auth/credential" "github.com/meshcloud/meshstack-cli/internal/http" "github.com/meshcloud/meshstack-cli/internal/meshstack" "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" @@ -15,18 +16,74 @@ import ( var _ http.Authorization = Session{} -func (s Session) GetBearerToken(ctx context.Context) (out http.BearerToken, err error) { - return s.RefreshBearerToken(ctx, "") +func (s Session) GetCredential(ctx context.Context) (http.Credential, error) { + return s.authorization().GetCredential(ctx) } -func (s Session) RefreshBearerToken(ctx context.Context, rejected http.BearerToken) (out http.BearerToken, err error) { +func (s Session) RefreshCredential(ctx context.Context, rejected http.Credential) (http.Credential, error) { + return s.authorization().RefreshCredential(ctx, rejected) +} + +func (s Session) authorization() http.Authorization { + switch cred := s.Credential.Credential.(type) { + case credential.Static: + return cred.Authorization() + case credential.Minter: + return mintedToken{s, cred} + default: + panic(fmt.Sprintf("credential %T neither mints a token nor is static", cred)) + } +} + +// CheckCredential fails where meshStack refuses the credential. +func (s Session) CheckCredential(ctx context.Context) error { + _, err := s.httpClient.WithAuthorization(s).DoRequest[[]byte](ctx, http.MethodGet, s.CurrentProfile.Endpoint.JoinPath("api", "meshobjects", "meshworkspaces")) + if httpErr, ok := errors.AsType[http.Error](err); ok && !httpErr.IsUnauthorized() { + // meshStack authenticates before it authorizes, so any other refusal accepted the credential. + return nil + } + return err +} + +func (s Session) GetBearerToken(ctx context.Context) (http.BearerToken, error) { + cred, err := s.GetCredential(ctx) + if err != nil { + return "", err + } + token, ok := cred.(http.BearerToken) + if !ok { + return "", fmt.Errorf("%s sends no bearer token", s.Credential.Name().Label()) + } + return token, nil +} + +// mintedToken is the Authorization of a credential that mints, caches and renews a bearer token. +type mintedToken struct { + session Session + minter credential.Minter +} + +func (t mintedToken) GetCredential(ctx context.Context) (http.Credential, error) { + return t.bearerToken(ctx, nil) +} + +func (t mintedToken) RefreshCredential(ctx context.Context, rejected http.Credential) (http.Credential, error) { + return t.bearerToken(ctx, rejected) +} + +func (t mintedToken) Scope() string { + return t.session.Scope() +} + +func (t mintedToken) bearerToken(ctx context.Context, rejected http.Credential) (out http.BearerToken, err error) { + s, minter := t.session, t.minter // Resolved before any cache lock is taken: resolving the workspace may list workspaces, and // that request needs a token of its own, which takes the same lock. A failure is returned by // the calls below, which get the memoized result. _, _ = s.getWorkspace() usable := func() bool { - token, found := s.Credential.CachedToken(ctx, s.getWorkspace) - if !found || token.GetClaim(jwt.ExpiryClaim).Expired(30*time.Second) || token.String() == string(rejected) { + token, found := minter.CachedToken(ctx, s.getWorkspace) + if !found || token.GetClaim(jwt.ExpiryClaim).Expired(30*time.Second) || http.BearerToken(token.String()) == rejected { return false } out = http.BearerToken(token.String()) @@ -48,11 +105,11 @@ func (s Session) RefreshBearerToken(ctx context.Context, rejected http.BearerTok // Not cancelled with ctx, so that Ctrl+C or a short deadline cannot cut a refresh off after the // issuer has rotated the refresh token: the rotated one would be lost, and with it the login. // Modify writes it to the cache before it returns, and the HTTP client's timeouts still end it. - if refreshErr := s.Credential.RefreshCachedToken(context.WithoutCancel(ctx), s.httpClient, s.getWorkspace); refreshErr != nil { + if refreshErr := minter.RefreshCachedToken(context.WithoutCancel(ctx), s.httpClient, s.getWorkspace); refreshErr != nil { return refreshErr } // RefreshCachedToken guarantees a cached token, so found is always true here. - token, _ := s.Credential.CachedToken(ctx, s.getWorkspace) + token, _ := minter.CachedToken(ctx, s.getWorkspace) out = http.BearerToken(token.String()) return nil }) diff --git a/internal/auth/credential/apikey.go b/internal/auth/credential/apikey.go index bb61217a..6009b79b 100644 --- a/internal/auth/credential/apikey.go +++ b/internal/auth/credential/apikey.go @@ -11,7 +11,7 @@ import ( "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" ) -var _ Credential = &ApiKey{} +var _ Minter = &ApiKey{} type ApiKey struct { Endpoint xurl.URL diff --git a/internal/auth/credential/basicauth.go b/internal/auth/credential/basicauth.go new file mode 100644 index 00000000..ada5f2b7 --- /dev/null +++ b/internal/auth/credential/basicauth.go @@ -0,0 +1,24 @@ +package credential + +import ( + "github.com/meshcloud/meshstack-cli/internal/http" +) + +var _ Static = &BasicAuth{} + +type BasicAuth struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (basic *BasicAuth) Name() Name { + return BasicAuthName +} + +func (basic *BasicAuth) Identity() Identity { + return identityOf(basic) +} + +func (basic *BasicAuth) Authorization() http.Authorization { + return http.BasicAuth(*basic) +} diff --git a/internal/auth/credential/credential.go b/internal/auth/credential/credential.go index 1b5e214d..92b27540 100644 --- a/internal/auth/credential/credential.go +++ b/internal/auth/credential/credential.go @@ -16,6 +16,11 @@ type Credential interface { Name() Name // Identity identifies the credential, so that a cache minted for another one is not used. Identity() Identity +} + +// Minter sends a bearer token that it mints and caches. +type Minter interface { + Credential // CachedToken does not check the expiry. The caller does, and calls RefreshCachedToken for an // expired token. CachedToken(ctx context.Context, getWorkspace getWorkspaceFunc) (token jwt.JWT, found bool) @@ -23,10 +28,17 @@ type Credential interface { RefreshCachedToken(ctx context.Context, client http.Client, getWorkspace getWorkspaceFunc) error } +// Static is sent with every request as it is. +type Static interface { + Credential + Authorization() http.Authorization +} + type getWorkspaceFunc func() (meshstack.Workspace, error) type Credentials struct { ApiKey *ApiKey `json:"apiKey,omitempty"` + BasicAuth *BasicAuth `json:"basicAuth,omitempty"` Manual *Manual `json:"manual,omitempty"` OidcLogin *OidcLogin `json:"oidcLogin,omitempty"` } diff --git a/internal/auth/credential/manual.go b/internal/auth/credential/manual.go index 55a27dbc..f8aa80e1 100644 --- a/internal/auth/credential/manual.go +++ b/internal/auth/credential/manual.go @@ -9,7 +9,7 @@ import ( "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" ) -var _ Credential = &Manual{} +var _ Minter = &Manual{} type Manual struct { Endpoint xurl.URL `json:"endpoint"` diff --git a/internal/auth/credential/name.go b/internal/auth/credential/name.go index ba93bed2..a728443a 100644 --- a/internal/auth/credential/name.go +++ b/internal/auth/credential/name.go @@ -14,12 +14,13 @@ func (n Name) String() string { const ( ApiKeyName Name = "apiKey" + BasicAuthName Name = "basicAuth" ManualName Name = "manual" OidcLoginName Name = "oidcLogin" ) // Names lists every credential, in the order a resolution tries and reports them. -var Names = []Name{ApiKeyName, ManualName, OidcLoginName} +var Names = []Name{ApiKeyName, BasicAuthName, ManualName, OidcLoginName} // A name that no field of Credentials carries resolves to nothing and stores to nowhere, without // saying so, which is why the two lists are checked against each other at startup. @@ -43,6 +44,8 @@ func (n Name) Label() string { switch n { case ApiKeyName: return "API key" + case BasicAuthName: + return "Basic auth" case ManualName: return "API token" case OidcLoginName: diff --git a/internal/auth/credential/oidclogin.go b/internal/auth/credential/oidclogin.go index f9533e40..ff20bbe9 100644 --- a/internal/auth/credential/oidclogin.go +++ b/internal/auth/credential/oidclogin.go @@ -16,7 +16,7 @@ import ( "github.com/meshcloud/meshstack-cli/internal/oidc/scope" ) -var _ Credential = &OidcLogin{} +var _ Minter = &OidcLogin{} type OidcLogin struct { Endpoint xurl.URL `json:"endpoint"` diff --git a/internal/auth/credential_apikey.go b/internal/auth/credential_apikey.go index e480acdc..f0ec4f19 100644 --- a/internal/auth/credential_apikey.go +++ b/internal/auth/credential_apikey.go @@ -2,7 +2,6 @@ package auth import ( "context" - "errors" "fmt" "log/slog" "uuid" @@ -34,19 +33,8 @@ var ApiKeyClientSecretSetting = setting.Setting[string]{ } func (s Session) resolveApiKeyCredential(ctx context.Context, settingSources setting.Sources) (Credential, error) { - apiKeyClientId, idSource, idErr := settingSources.ResolveSettingWithSource(ctx, ApiKeyClientIdSetting) - apiKeyClientSecret, secretSource, secretErr := settingSources.ResolveSettingWithSource(ctx, ApiKeyClientSecretSetting) - idMissing := errors.Is(idErr, setting.ErrNoSourceProvidedValue) - secretMissing := errors.Is(secretErr, setting.ErrNoSourceProvidedValue) - switch { - case idMissing && secretMissing: - return Credential{}, errors.Join(idErr, secretErr) - case idMissing || secretMissing: - // Error(), not %w: the sentinel in the chain reads as "no API key was mentioned" and is skipped. - return Credential{}, errors.New("an API key needs " + ApiKeyClientIdSetting.EnvKey() + " and " + - ApiKeyClientSecretSetting.EnvKey() + " together: " + errors.Join(idErr, secretErr).Error()) - } - if err := errors.Join(idErr, secretErr); err != nil { + apiKeyClientId, apiKeyClientSecret, sources, err := resolveBoth(ctx, settingSources, "an API key", ApiKeyClientIdSetting, ApiKeyClientSecretSetting) + if err != nil { return Credential{}, err } slog.DebugContext(ctx, fmt.Sprintf("Using api key credentials (client id %s with %d bytes long secret)", apiKeyClientId, len(apiKeyClientSecret))) @@ -55,9 +43,6 @@ func (s Session) resolveApiKeyCredential(ctx context.Context, settingSources set ClientId: apiKeyClientId, ClientSecret: apiKeyClientSecret, }) - apiKey.Sources = []string{ - idSource.Describe(ApiKeyClientIdSetting.EnvKey()), - secretSource.Describe(ApiKeyClientSecretSetting.EnvKey()), - } + apiKey.Sources = sources return apiKey, nil } diff --git a/internal/auth/credential_basicauth.go b/internal/auth/credential_basicauth.go new file mode 100644 index 00000000..b1759b4a --- /dev/null +++ b/internal/auth/credential_basicauth.go @@ -0,0 +1,43 @@ +package auth + +import ( + "context" + "fmt" + "log/slog" + + "github.com/meshcloud/meshstack-cli/internal/auth/credential" + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +var ApiUsernameSetting = setting.Setting[string]{ + Env: "MESHSTACK_API_USERNAME", + Short: func(envKey string) string { + return fmt.Sprintf("The username that every request sends by HTTP basic auth, together with its password. Also read from %s.", envKey) + }, + Long: func(envKey string) string { + return fmt.Sprintf("The username that every request sends by HTTP basic auth, together with its password, also read from `%s`.", envKey) + }, + Parse: setting.ParseText[string], +} + +var ApiPasswordSetting = setting.Setting[string]{ + Env: "MESHSTACK_API_PASSWORD", + Short: func(envKey string) string { + return fmt.Sprintf("The password belonging to the username. Also read from %s.", envKey) + }, + Long: func(envKey string) string { + return fmt.Sprintf("The password belonging to the username, also read from `%s`.", envKey) + }, + Parse: setting.ParseText[string], +} + +func (s Session) resolveBasicAuthCredential(ctx context.Context, settingSources setting.Sources) (Credential, error) { + username, password, sources, err := resolveBoth(ctx, settingSources, "basic auth", ApiUsernameSetting, ApiPasswordSetting) + if err != nil { + return Credential{}, err + } + slog.DebugContext(ctx, fmt.Sprintf("Using basic auth (username %s with %d bytes long password)", username, len(password))) + basicAuth := CacheFor(s.CurrentProfile, &credential.BasicAuth{Username: username, Password: password}) + basicAuth.Sources = sources + return basicAuth, nil +} diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go index 2b2917a7..c0c63bbc 100644 --- a/internal/auth/credentials.go +++ b/internal/auth/credentials.go @@ -26,6 +26,7 @@ func (s Session) resolveCredentials(ctx context.Context, opts ResolveSessionOpti // No browser login here: it needs a person, so only Login reaches it, and the Terraform // provider resolves a session on every plan and must never open a browser. collect(s.resolveApiKeyCredential(ctx, opts.SettingSources)) + collect(s.resolveBasicAuthCredential(ctx, opts.SettingSources)) collect(s.resolveManualCredential(ctx, opts.SettingSources)) if err := errors.Join(errs...); err != nil { return Credential{}, err @@ -67,3 +68,23 @@ func (s Session) storedCredential(ctx context.Context, noSourceErrs []error) (Cr stored.Sources, stored.Stored = []string{"file " + creds.FilePath}, true return stored, nil } + +// resolveBoth resolves two settings that make up one credential, and fails where only one is set. +func resolveBoth[A, B any](ctx context.Context, settingSources setting.Sources, credentialLabel string, a setting.Setting[A], b setting.Setting[B]) (valueA A, valueB B, sources []string, err error) { + valueA, sourceA, errA := settingSources.ResolveSettingWithSource(ctx, a) + valueB, sourceB, errB := settingSources.ResolveSettingWithSource(ctx, b) + missingA := errors.Is(errA, setting.ErrNoSourceProvidedValue) + missingB := errors.Is(errB, setting.ErrNoSourceProvidedValue) + switch { + case missingA && missingB: + return valueA, valueB, nil, errors.Join(errA, errB) + case missingA || missingB: + // Flattened to text, since the caller skips a credential whose error wraps ErrNoSourceProvidedValue. + return valueA, valueB, nil, errors.New(credentialLabel + " needs " + a.EnvKey() + " and " + b.EnvKey() + + " together: " + errors.Join(errA, errB).Error()) + } + if err := errors.Join(errA, errB); err != nil { + return valueA, valueB, nil, err + } + return valueA, valueB, []string{sourceA.Describe(a.EnvKey()), sourceB.Describe(b.EnvKey())}, nil +} diff --git a/internal/auth/login.go b/internal/auth/login.go index ccb87644..545c53d5 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -46,6 +46,8 @@ func Login(ctx context.Context, withAuth credential.Name, opts ResolveSessionOpt resolved, err = session.resolveManualCredential(ctx, opts.SettingSources) case credential.ApiKeyName: resolved, err = session.resolveApiKeyCredential(ctx, opts.SettingSources) + case credential.BasicAuthName: + resolved, err = session.resolveBasicAuthCredential(ctx, opts.SettingSources) default: return Session{}, nil, nil, fmt.Errorf("cannot authenticate with credential '%s'; pick one of %v", withAuth, credential.Names) } diff --git a/internal/auth/login_test.go b/internal/auth/login_test.go index 274645b0..dade9966 100644 --- a/internal/auth/login_test.go +++ b/internal/auth/login_test.go @@ -19,7 +19,7 @@ func TestLoginRefusesAnUnknownCredential(t *testing.T) { newTestServer(t) _, _, _, err := auth.Login(t.Context(), "nope", testSessionOpts) - require.ErrorContains(t, err, "cannot authenticate with credential 'nope'; pick one of [apiKey manual oidcLogin]") + require.ErrorContains(t, err, "cannot authenticate with credential 'nope'; pick one of [apiKey basicAuth manual oidcLogin]") profiles, err := profile.LoadProfiles(t.Context(), profile.LoadProfilesOptions{ExclusiveLock: true}) require.NoError(t, err, "a login that fails holds no lock on the profiles") diff --git a/internal/auth/session_stress_test.go b/internal/auth/session_stress_test.go index f55d7dc2..7ebafdf8 100644 --- a/internal/auth/session_stress_test.go +++ b/internal/auth/session_stress_test.go @@ -175,7 +175,7 @@ func (r *stressResolver) askConcurrently(t *testing.T, ctx context.Context, serv } // revokeTokens stops honoring the token the next session will find in the cache file, so that -// its five callers all meet a 401 at once and go through auth.Session.RefreshBearerToken +// its five callers all meet a 401 at once and go through auth.Session.RefreshCredential // together. Exactly one of them may then mint, which is what the login count checks. // // It revokes only between rounds, as [fakemeshstack.Server.RevokeNewestToken] requires. diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go index 3cee623d..6145780f 100644 --- a/internal/auth/session_test.go +++ b/internal/auth/session_test.go @@ -56,6 +56,21 @@ func TestSessionResolutionSaysWhyItFails(t *testing.T) { require.ErrorContains(t, err, auth.ApiTokenSetting.EnvKey()) require.ErrorContains(t, err, auth.ApiKeyClientIdSetting.EnvKey()) require.ErrorContains(t, err, auth.ApiKeyClientSecretSetting.EnvKey()) + require.ErrorContains(t, err, auth.ApiUsernameSetting.EnvKey()) + }) + + t.Run("with a username but no password", func(t *testing.T) { + t.Setenv(auth.ApiUsernameSetting.EnvKey(), testBasicAuth.Username) + _, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.ErrorContains(t, err, "together") + require.ErrorContains(t, err, auth.ApiPasswordSetting.EnvKey()) + }) + + t.Run("with basic auth next to an API key", func(t *testing.T) { + setApiKeyEnv(t, testApiKey1) + setBasicAuthEnv(t, testBasicAuth) + _, err := auth.ResolveSession(t.Context(), testSessionOpts) + assert.ErrorContains(t, err, "more than one credential") }) t.Run("with half an API key", func(t *testing.T) { @@ -128,6 +143,62 @@ func TestSessionOfAStoredApiKey(t *testing.T) { }) } +func TestSessionWithBasicAuth(t *testing.T) { + server := newTestServer(t) + + t.Run("sends the username and password with every request and logs in nowhere", func(t *testing.T) { + setBasicAuthEnv(t, testBasicAuth) + session, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.NoError(t, err) + requireAnswer(t, server, session) + requireAnswer(t, server, session) + assert.Equal(t, fakemeshstack.Counts{Authorized: 2}, server.Counts()) + }) + + t.Run("a refused password is asked once", func(t *testing.T) { + setBasicAuthEnv(t, http.BasicAuth{Username: testBasicAuth.Username, Password: "wrong"}) + session, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.NoError(t, err) + var httpErr http.Error + require.ErrorAs(t, ask(t.Context(), server, session), &httpErr) + assert.True(t, httpErr.IsUnauthorized()) + assert.EqualValues(t, 1, server.Counts().UnknownTokens) + }) + + t.Run("a check refuses a wrong password and accepts the right one", func(t *testing.T) { + setBasicAuthEnv(t, http.BasicAuth{Username: testBasicAuth.Username, Password: "wrong"}) + session, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.NoError(t, err) + var httpErr http.Error + require.ErrorAs(t, session.CheckCredential(t.Context()), &httpErr) + assert.True(t, httpErr.IsUnauthorized()) + + setBasicAuthEnv(t, testBasicAuth) + session, err = auth.ResolveSession(t.Context(), testSessionOpts) + require.NoError(t, err) + require.NoError(t, session.CheckCredential(t.Context())) + }) + + t.Run("sends no bearer token", func(t *testing.T) { + setBasicAuthEnv(t, testBasicAuth) + session, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.NoError(t, err) + _, err = session.GetBearerToken(t.Context()) + require.EqualError(t, err, "Basic auth sends no bearer token") + }) + + t.Run("a login stores it for the next command", func(t *testing.T) { + setBasicAuthEnv(t, testBasicAuth) + storeLogin(t, credential.BasicAuthName) + + setBasicAuthEnv(t, http.BasicAuth{}) + session, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.NoError(t, err) + assert.Equal(t, credential.BasicAuthName, session.Credential.Name()) + requireAnswer(t, server, session) + }) +} + func TestSessionOfAStoredManualToken(t *testing.T) { server := newTestServer(t) t.Setenv(auth.ApiTokenSetting.EnvKey(), server.MintToken(time.Hour)) @@ -168,6 +239,7 @@ var ( testSessionOpts = auth.ResolveSessionOptions{Version: "dev", GitHubRepo: "meshcloud/test-client"} testApiKey1 = fakemeshstack.ApiKey{ClientId: "11111111-45bf-42ba-a965-2097b9d0d181", ClientSecret: "super-test-secret-1"} testApiKey2 = fakemeshstack.ApiKey{ClientId: "22222222-45bf-42ba-a965-2097b9d0d181", ClientSecret: "super-test-secret-2"} + testBasicAuth = http.BasicAuth{Username: "runner", Password: "super-test-password"} ) func newTestServer(t *testing.T) *fakemeshstack.Server { @@ -182,10 +254,15 @@ func newTestServer(t *testing.T) *fakemeshstack.Server { auth.ApiKeyClientIdSetting.EnvKey(), auth.ApiKeyClientSecretSetting.EnvKey(), auth.ApiTokenSetting.EnvKey(), + auth.ApiUsernameSetting.EnvKey(), + auth.ApiPasswordSetting.EnvKey(), } { t.Setenv(envKey, "") } - server := fakemeshstack.Start(t, fakemeshstack.Options{ApiKeys: []fakemeshstack.ApiKey{testApiKey1, testApiKey2}}) + server := fakemeshstack.Start(t, fakemeshstack.Options{ + ApiKeys: []fakemeshstack.ApiKey{testApiKey1, testApiKey2}, + BasicAuth: []http.BasicAuth{testBasicAuth}, + }) t.Setenv(config.DirectorySetting.EnvKey(), t.TempDir()) t.Setenv(meshstack.EndpointSetting.EnvKey(), server.URL) return server @@ -197,6 +274,12 @@ func setApiKeyEnv(t *testing.T, key fakemeshstack.ApiKey) { t.Setenv(auth.ApiKeyClientSecretSetting.EnvKey(), key.ClientSecret) } +func setBasicAuthEnv(t *testing.T, basic http.BasicAuth) { + t.Helper() + t.Setenv(auth.ApiUsernameSetting.EnvKey(), basic.Username) + t.Setenv(auth.ApiPasswordSetting.EnvKey(), basic.Password) +} + // ask brings its own http.Client, because the session keeps its own to itself. What these tests // drive is the authorization, which the session supplies either way. func ask(ctx context.Context, server *fakemeshstack.Server, session auth.Session) error { diff --git a/internal/auth/status.go b/internal/auth/status.go index 539a3568..a9fc07cf 100644 --- a/internal/auth/status.go +++ b/internal/auth/status.go @@ -37,6 +37,7 @@ type CredentialStatus struct { // an API key minted. OidcLogin *OidcLoginStatus `json:"oidcLogin,omitzero"` ApiKey *ApiKeyStatus `json:"apiKey,omitzero"` + BasicAuth *BasicAuthStatus `json:"basicAuth,omitzero"` Unused []UnusedCredential `json:"unusedCredentials,omitzero"` } @@ -48,6 +49,7 @@ type UnusedCredential struct { Selected bool `json:"selected,omitzero"` // ClientId is set for an API key. ClientId string `json:"clientId,omitzero"` + Username string `json:"username,omitzero"` // Token is the cached token of a browser login or an API token. Token *TokenStatus `json:"token,omitzero"` } @@ -93,6 +95,10 @@ func BrowserLoginEnded(ctx context.Context, p *profile.Profile) bool { return OidcLoginStatus{SessionEndsAt: creds.OidcLogin.Cache.RefreshExpiresAt}.SessionEnded() } +type BasicAuthStatus struct { + Username string `json:"username"` +} + type ApiKeyStatus struct { ClientId uuid.UUID `json:"clientId"` Details *ApiKeyDetails `json:"details,omitzero"` @@ -139,6 +145,8 @@ func (s Session) Status(ctx context.Context) CredentialStatus { case *credential.ApiKey: status.ApiKey = &ApiKeyStatus{ClientId: cred.ClientId, Details: apiKeyDetails(ctx, s.Client)} token, found = cred.CachedToken(ctx, s.getWorkspace) + case *credential.BasicAuth: + status.BasicAuth = &BasicAuthStatus{Username: cred.Username} case *credential.Manual: token, found = cred.CachedToken(ctx, s.getWorkspace) // A pasted API token is most likely the token of a building block run, which its ephemeral @@ -175,6 +183,8 @@ func (s Session) unusedCredentials(ctx context.Context, workspace meshstack.Work switch cred := cred.(type) { case *credential.ApiKey: entry.ClientId = cred.ClientId.String() + case *credential.BasicAuth: + entry.Username = cred.Username case *credential.OidcLogin: if token, found := oidcTokenFor(cred, workspace); found { entry.Token = tokenStatusOf(token) diff --git a/internal/http/auth.go b/internal/http/auth.go index d0a6cea8..2e08e4bf 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -2,27 +2,34 @@ package http import ( "context" + "encoding/base64" "errors" "fmt" + "io" "log/slog" gohttp "net/http" "net/url" ) -// Authorization produces the bearer token for each request. An implementation renews the token -// it holds once that token is close to expiry, so that [AuthorizedClient.DoRequest] only has to -// handle the 401 a token lost earlier than expected. +// Authorization produces the credential for each request. An implementation renews the credential +// it holds once that is close to expiry, so that [AuthorizedClient.DoRequest] only has to handle +// the 401 a credential lost earlier than expected. type Authorization interface { - GetBearerToken(ctx context.Context) (BearerToken, error) + GetCredential(ctx context.Context) (Credential, error) - // RefreshBearerToken returns the token that replaces the rejected one. Returning the - // rejected token unchanged tells the caller that there is nothing new to try. - RefreshBearerToken(ctx context.Context, rejected BearerToken) (BearerToken, error) + // RefreshCredential returns the credential that replaces the rejected one. Returning the + // rejected credential unchanged tells the caller that there is nothing new to try. + RefreshCredential(ctx context.Context, rejected Credential) (Credential, error) // Scope names what meshStack grants access by, so that a 403 says it. Empty names nothing. Scope() string } +// Credential is comparable, so that a refresh can tell whether it produced anything new. +type Credential interface { + authorizationHeader() string +} + func (c Client) WithAuthorization(auth Authorization) AuthorizedClient { return AuthorizedClient{c, auth} } @@ -34,8 +41,8 @@ type AuthorizedClient struct { } func (c AuthorizedClient) DoRequest[R any](ctx context.Context, method string, url *url.URL, options ...RequestOption) (R, error) { - result, err := withBearerToken(ctx, c.Authorization, method, url, func(token RequestOption) (R, error) { - return c.Client.DoRequest[R](ctx, method, url, append(options, token)...) + result, err := withCredential(ctx, c.Authorization, method, url, func(credential RequestOption) (R, error) { + return c.Client.DoRequest[R](ctx, method, url, append(options, credential)...) }) if httpErr, ok := errors.AsType[Error](err); ok && httpErr.IsForbidden() && c.Authorization.Scope() != "" { err = fmt.Errorf("auth scope %s: %w", c.Authorization.Scope(), err) @@ -43,56 +50,85 @@ func (c AuthorizedClient) DoRequest[R any](ctx context.Context, method string, u return result, err } +func (c AuthorizedClient) DoStreamingRequest(ctx context.Context, method string, url *url.URL, w io.Writer, options ...RequestOption) error { + _, err := withCredential(ctx, c.Authorization, method, url, func(credential RequestOption) (any, error) { + return nil, c.Client.DoStreamingRequest(ctx, method, url, w, append(options, credential)...) + }) + return err +} + // Authorize is for a request that this client does not send itself, such as one a reverse proxy // passes on. Such a request gets no second try after a 401. func (c AuthorizedClient) Authorize(r *gohttp.Request) error { - token, err := c.Authorization.GetBearerToken(r.Context()) + credential, err := c.Authorization.GetCredential(r.Context()) if err != nil { return err } - r.Header.Set("Authorization", "Bearer "+string(token)) + r.Header.Set("Authorization", credential.authorizationHeader()) return nil } -func withBearerToken[R any](ctx context.Context, auth Authorization, method string, url *url.URL, send func(token RequestOption) (R, error)) (result R, err error) { - token, tokenErr := auth.GetBearerToken(ctx) - if tokenErr != nil { - return result, tokenErr +func withCredential[R any](ctx context.Context, auth Authorization, method string, url *url.URL, send func(credential RequestOption) (R, error)) (result R, err error) { + credential, credentialErr := auth.GetCredential(ctx) + if credentialErr != nil { + return result, credentialErr } - result, err = send(token.asRequestOption()) + result, err = send(withAuthorizationHeader(credential)) if httpErr, ok := errors.AsType[Error](err); ok && httpErr.IsUnauthorized() { // Clock skew between this machine and the server can make a token look valid here and // expired there, so one 401 earns one retry with a freshly minted token. - refreshedToken, refreshErr := auth.RefreshBearerToken(ctx, token) + refreshed, refreshErr := auth.RefreshCredential(ctx, credential) switch { case refreshErr != nil: - return result, errors.Join(err, fmt.Errorf("cannot renew the rejected token: %w", refreshErr)) - case refreshedToken == token: + return result, errors.Join(err, fmt.Errorf("cannot renew the rejected credential: %w", refreshErr)) + case refreshed == credential: return result, err } - slog.DebugContext(ctx, "retrying after 401 with a freshly minted token", "url", url.String(), "method", method) - return send(refreshedToken.asRequestOption()) + slog.DebugContext(ctx, "retrying after 401 with a freshly minted credential", "url", url.String(), "method", method) + return send(withAuthorizationHeader(refreshed)) } return result, err } -// BearerToken is both the token Authorization produces and an Authorization of its own, for a -// caller that holds one static token. +func withAuthorizationHeader(credential Credential) RequestOption { + return withHeader("Authorization", credential.authorizationHeader()) +} + type BearerToken string -func (token BearerToken) GetBearerToken(_ context.Context) (BearerToken, error) { +func (token BearerToken) GetCredential(context.Context) (Credential, error) { return token, nil } -func (token BearerToken) RefreshBearerToken(_ context.Context, _ BearerToken) (BearerToken, error) { - return "", fmt.Errorf("cannot renew %T", token) +func (token BearerToken) RefreshCredential(context.Context, Credential) (Credential, error) { + return nil, fmt.Errorf("cannot renew %T", token) } func (token BearerToken) Scope() string { return "" } -func (token BearerToken) asRequestOption() RequestOption { - return withHeader("Authorization", fmt.Sprintf("Bearer %s", token)) +func (token BearerToken) authorizationHeader() string { + return "Bearer " + string(token) +} + +type BasicAuth struct { + Username, Password string +} + +func (basic BasicAuth) GetCredential(context.Context) (Credential, error) { + return basic, nil +} + +func (basic BasicAuth) RefreshCredential(context.Context, Credential) (Credential, error) { + return nil, fmt.Errorf("cannot renew %T", basic) +} + +func (basic BasicAuth) Scope() string { + return "" +} + +func (basic BasicAuth) authorizationHeader() string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(basic.Username+":"+basic.Password)) } diff --git a/internal/http/http_client.go b/internal/http/http_client.go index 4a893532..cf8919b8 100644 --- a/internal/http/http_client.go +++ b/internal/http/http_client.go @@ -8,6 +8,7 @@ import ( "log/slog" gohttp "net/http" "net/url" + "os" "reflect" "slices" "time" @@ -76,6 +77,24 @@ func (c Client) DoRequest[R any](ctx context.Context, method string, url *url.UR return } +// DoStreamingRequest copies a 2xx body to w as it arrives. +func (c Client) DoStreamingRequest(ctx context.Context, method string, url *url.URL, w io.Writer, options ...RequestOption) error { + res, err := c.send(ctx, method, url, options) + if err != nil { + return err + } + defer func() { + _ = res.Body.Close() + }() + if !isSuccess(res.StatusCode) { + _, err = c.readBodyAndCheckSuccess(ctx, res) + return err + } + written, err := io.Copy(w, res.Body) + slog.DebugContext(ctx, "response", "status", res.StatusCode, "body", fmt.Sprintf("", written)) + return err +} + func (c Client) doRequest(ctx context.Context, method string, url *url.URL, options []RequestOption) ([]byte, error) { res, err := c.send(ctx, method, url, options) if err != nil { @@ -122,7 +141,7 @@ func (c Client) readBodyAndCheckSuccess(ctx context.Context, res *gohttp.Respons } func (c Client) buildRequest(ctx context.Context, method string, url *url.URL, opts requestOptions) (*gohttp.Request, error) { - var requestBody io.ReadWriter + var requestBody io.Reader if opts.requestPayload != nil { requestBodyData, err := opts.requestPayload() if err != nil { @@ -139,6 +158,12 @@ func (c Client) buildRequest(ctx context.Context, method string, url *url.URL, o if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } + if opts.bodyFile != "" { + if err := setFileBody(req, opts.bodyFile); err != nil { + return nil, err + } + requestBody = req.Body + } for _, modifier := range opts.requestModifiers { if err := modifier(req); err != nil { return nil, err @@ -147,3 +172,20 @@ func (c Client) buildRequest(ctx context.Context, method string, url *url.URL, o slog.DebugContext(ctx, "request", "url", req.URL.String(), "method", req.Method, "headers", loggedHeaders(req.Header), "body", loggedBody{requestBody}) return req, nil } + +// setFileBody streams the file, and reopens it when a retry replays the request. +func setFileBody(req *gohttp.Request, path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + open := func() (io.ReadCloser, error) { + return os.Open(path) //nolint:gosec // the caller names the file it uploads + } + if req.Body, err = open(); err != nil { + return err + } + req.GetBody = open + req.ContentLength = info.Size() + return nil +} diff --git a/internal/http/http_client_test.go b/internal/http/http_client_test.go index ddd57ca1..1b60e79d 100644 --- a/internal/http/http_client_test.go +++ b/internal/http/http_client_test.go @@ -12,6 +12,8 @@ import ( gohttp "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "testing" "testing/synctest" "time" @@ -171,6 +173,25 @@ func TestHttpClient(t *testing.T) { } }) + t.Run("DoRequest reopens a body file to replay it", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "plan") + require.NoError(t, os.WriteFile(path, []byte("a plan"), 0o600)) + var bodies []string + client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) { + body, _ := io.ReadAll(req.Body) + bodies = append(bodies, string(body)) + if len(bodies) == 1 { + resp.WriteHeader(gohttp.StatusServiceUnavailable) + return + } + resp.WriteHeader(gohttp.StatusNoContent) + }), http.RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{}}) + + _, err := client.DoRequest[any](t.Context(), gohttp.MethodPut, client.ServerUrl, http.WithBodyFile(path), http.Retryable()) + require.NoError(t, err) + assert.Equal(t, []string{"a plan", "a plan"}, bodies) + }) + t.Run("DoRequest re-mints once on 401", func(t *testing.T) { t.Run("retries with the freshly minted token", func(t *testing.T) { auth := &refreshableAuthorization{token: "stale"} @@ -186,7 +207,7 @@ func TestHttpClient(t *testing.T) { _, err := client.WithAuthorization(auth).DoRequest[any](t.Context(), gohttp.MethodPut, client.ServerUrl.JoinPath("edit")) require.NoError(t, err) assert.Equal(t, []string{"Bearer stale", "Bearer fresh"}, seen) - assert.Equal(t, []http.BearerToken{"stale"}, auth.rejected, "the token that was refused is what the refresh is told about") + assert.Equal(t, []http.Credential{http.BearerToken("stale")}, auth.rejected, "the token that was refused is what the refresh is told about") }) t.Run("reports the 401 when the re-mint changes nothing", func(t *testing.T) { @@ -219,17 +240,33 @@ func TestHttpClient(t *testing.T) { }) t.Run("leaves an authorization that cannot re-mint alone", func(t *testing.T) { - attempts := 0 - client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { - attempts++ - resp.WriteHeader(gohttp.StatusUnauthorized) - }) - _, err := client.WithAuthorization(http.BearerToken("static")).DoRequest[any](t.Context(), gohttp.MethodPut, client.ServerUrl.JoinPath("edit")) - var httpErr http.Error - require.ErrorAs(t, err, &httpErr) - assert.Equal(t, gohttp.StatusUnauthorized, httpErr.StatusCode) - assert.Equal(t, 1, attempts) + for _, static := range []http.Authorization{http.BearerToken("static"), http.BasicAuth{Username: "runner", Password: "secret"}} { + attempts := 0 + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + attempts++ + resp.WriteHeader(gohttp.StatusUnauthorized) + }) + _, err := client.WithAuthorization(static).DoRequest[any](t.Context(), gohttp.MethodPut, client.ServerUrl.JoinPath("edit")) + var httpErr http.Error + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, gohttp.StatusUnauthorized, httpErr.StatusCode) + assert.Equal(t, 1, attempts, "%T", static) + } + }) + }) + + t.Run("BasicAuth sends the username and password as a Basic header", func(t *testing.T) { + var username, password string + var ok bool + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) { + username, password, ok = req.BasicAuth() + resp.WriteHeader(gohttp.StatusNoContent) }) + _, err := client.WithAuthorization(http.BasicAuth{Username: "runner", Password: "p:ss"}).DoRequest[any](t.Context(), gohttp.MethodGet, client.ServerUrl) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "runner", username) + assert.Equal(t, "p:ss", password) }) t.Run("DoRequest of []byte returns the retried answer as it came, empty included", func(t *testing.T) { @@ -258,10 +295,10 @@ type refreshableAuthorization struct { token http.BearerToken keepToken bool refreshErr error - rejected []http.BearerToken + rejected []http.Credential } -func (a *refreshableAuthorization) GetBearerToken(context.Context) (http.BearerToken, error) { +func (a *refreshableAuthorization) GetCredential(context.Context) (http.Credential, error) { return a.token, nil } @@ -269,10 +306,10 @@ func (a *refreshableAuthorization) Scope() string { return "" } -func (a *refreshableAuthorization) RefreshBearerToken(_ context.Context, rejected http.BearerToken) (http.BearerToken, error) { +func (a *refreshableAuthorization) RefreshCredential(_ context.Context, rejected http.Credential) (http.Credential, error) { a.rejected = append(a.rejected, rejected) if a.refreshErr != nil { - return "", a.refreshErr + return nil, a.refreshErr } if !a.keepToken { a.token = "fresh" diff --git a/internal/http/logging.go b/internal/http/logging.go index d6bfadb4..951758e1 100644 --- a/internal/http/logging.go +++ b/internal/http/logging.go @@ -9,6 +9,7 @@ import ( "io" "maps" gohttp "net/http" + "os" "regexp" "slices" "strings" @@ -25,7 +26,7 @@ import ( // Enabled says yes to all of them and terraform owns the level, so a LogValuer would // pretty-print every request body of every run including the ones TF_LOG then drops. -// loggedHeaders is the request's headers with the bearer token taken out. Both methods produce +// loggedHeaders is the request's headers with the credential taken out. Both methods produce // that redacted form, because both are reached: the CLI's sink formats with %v and calls String, // while terraform's sink encodes the fields as JSON and would otherwise walk this map itself and // write the Authorization header out in full. @@ -74,6 +75,8 @@ func (l loggedBody) String() string { return "" case *bytes.Buffer: return bytesToPrettyJson(body.Bytes()) + case *os.File: + return "" default: return fmt.Sprintf(" %v", body) } diff --git a/internal/http/method.go b/internal/http/method.go index e98d85f2..d2c2fefd 100644 --- a/internal/http/method.go +++ b/internal/http/method.go @@ -8,5 +8,6 @@ const ( MethodGet = gohttp.MethodGet MethodPost = gohttp.MethodPost MethodPut = gohttp.MethodPut + MethodPatch = gohttp.MethodPatch MethodDelete = gohttp.MethodDelete ) diff --git a/internal/http/options.go b/internal/http/options.go index d53b09a3..67cd904c 100644 --- a/internal/http/options.go +++ b/internal/http/options.go @@ -18,6 +18,7 @@ type ( requestOptions struct { retryable bool requestPayload func() ([]byte, error) + bodyFile string requestModifiers []requestModifier } requestModifier func(req *gohttp.Request) error @@ -139,6 +140,13 @@ func WithBody(body []byte) RequestOption { } } +// WithBodyFile sends the file as it is, so the caller names its type in a Content-Type header. +func WithBodyFile(path string) RequestOption { + return func(opts *requestOptions) { + opts.bodyFile = path + } +} + func withHeader(key, value string) RequestOption { return appendRequestModifier(func(req *gohttp.Request) error { req.Header.Set(key, value) diff --git a/internal/testutil/fakemeshstack/fakemeshstack.go b/internal/testutil/fakemeshstack/fakemeshstack.go index 11b60027..98733901 100644 --- a/internal/testutil/fakemeshstack/fakemeshstack.go +++ b/internal/testutil/fakemeshstack/fakemeshstack.go @@ -48,8 +48,9 @@ type Options struct { AdminWorkspace string // Issuer makes the Server the Keycloak of meshStack as well, under the path of the issuer. A // caller that serves the issuer's host elsewhere routes that host to the Server too. - Issuer string - ApiKeys []ApiKey + Issuer string + ApiKeys []ApiKey + BasicAuth []http.BasicAuth // The objects are marshaled on every request, so a test may change one between steps. A // workspace is found by metadata.name, every other object by metadata.uuid. @@ -255,6 +256,9 @@ func (s *Server) authorized(next gohttp.HandlerFunc) gohttp.HandlerFunc { token, _ := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") s.mu.Lock() honored, minted := s.minted[token] + if username, password, ok := r.BasicAuth(); ok { + honored = slices.Contains(s.options.BasicAuth, http.BasicAuth{Username: username, Password: password}) + } switch { case honored: s.counts.Authorized++ diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index d48a9407..adfc0cc0 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -25,4 +25,6 @@ var ( ApiKeyClientId Setting = auth.ApiKeyClientIdSetting ApiKeyClientSecret Setting = auth.ApiKeyClientSecretSetting ApiToken Setting = auth.ApiTokenSetting + ApiUsername Setting = auth.ApiUsernameSetting + ApiPassword Setting = auth.ApiPasswordSetting )