diff --git a/AGENTS.md b/AGENTS.md index 13d3668..351676c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,15 @@ agent clients. **The runtime runs only under the authority.** The saas-host mode — a bearer forwarded to the host's gateway, `ForModule` minting a Task Work Context through accounts, the broker execution credential, `Transcoded`/`Unary` -calls over a module's REST binding, the install step, gateway-stamped -identity headers and the MCP surface's OAuth discovery against the host's -issuer — is **deleted, not fenced**. A boot whose composition names no +calls over a module's REST binding, the install step verified from its +carrier alone, gateway-stamped identity headers and the MCP surface's OAuth +discovery against the host's issuer — is **deleted, not fenced**. The install +step is back under the authority (`installation.go`). The authority's `Check` +at `POST /_codefly/installations` is its dedicated step verifier. A step runs +only after that check, and only when the body names exactly what was verified. +It is given no Gateway, and a solution with no `OnInstall` is never +acknowledged. Do not let a remembered acknowledgement answer before the check, +and do not hand the step anything to act under. A boot whose composition names no `authority/endpoint` is refused. If you are about to add a path that reaches a module, or learns who a viewer is, without the authority, you are rebuilding what this repository deleted. diff --git a/CHANGELOG.md b/CHANGELOG.md index d928457..aa56574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## Unreleased — the install step, verified by the authority + +- **`OnInstall`, `Installation`, `RefuseInstall`, `ErrInstallRefused`** are back, + additive to the current API. The host delivers a pending installation's setup + to `POST /_codefly/installations`, whose body is exactly + `{installation_id, organization_id, revision}`. + The runtime asks the authority's `Check` at that method, which is the + authority's dedicated, never-advisory step verifier. The body and the + installation carriers must then name exactly the organization, installation + and revision it verified, with a consent operation and only + `installations:install` on that installation. +- `Installation` is the verified facts, with the new `OperationID` (the consent + operation, the verified task id). An authority refusal answers 409 with one + generic reason, as does a refusal answered beside the verified context. A body carrying + any other member, `scope` included, is refused 400. +- The step is given no Gateway, and a solution with no step is answered 409, + never 204. +- One (installation, revision, consent operation) runs once per process. A repeat is + verified before a remembered 204 answers it. +- What the e80369a deletion removed stays removed: a carrier parsed alone, a + nil step acknowledged, and a step acting under the carried capability. + ## Unreleased — an operation names the tool it is offered as - **`Operation.Tool`** is the model-facing name of a marked read. Empty keeps diff --git a/README.md b/README.md index ca3fc83..ae2f27a 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,14 @@ the host's gateway, a Task Work Context minted through accounts for the viewer from the host's module broker (`Server.Credential`, `CredentialSource`, the `module-authority` group), a module called over its REST binding (`Gateway.Transcoded`, `Unary`, `HTTPClient`, `BaseURL`), the install step -(`OnInstall`, `Installation`, `RefuseInstall`), the identity headers the +the host verified itself from a carried capability, the identity headers the gateway stamped (`InstallationID`, `x-org-id`, `x-session-id`), and the MCP surface's OAuth discovery against the host's issuer. All of it is **deleted, not fenced**: a solution that consumes modules relays them only through the authority, and no path of the saas mode was still reachable. A composition that names no `authority/endpoint` is refused at boot. Nothing bridges the two -models. +models. The install step came back under the authority +([below](#the-install-step)): the authority verifies it, not the carrier. ## A runtime does not register itself @@ -179,6 +180,65 @@ the projected token at `/var/run/secrets/codefly/token` (overridden by is rotated under the running process. Declare `module-identity` on the backend so the run projects it. +### The install step + +The host records an installation **pending** and delivers its setup to +`POST /_codefly/installations` on this listener. The installation becomes +active only after this answers 204 for it, and `OnInstall(step)` is what +answers. The request carries a **setup-only credential** that the authority +issued for that one pending attempt, in `x-codefly-work-context`, with +`x-codefly-installation-id` and `x-codefly-installation-revision` beside it. Its +body is exactly `{installation_id, organization_id, revision}`; any other member, +a `scope` among them, is refused 400. + +- **The authority verifies the step, and nothing else does.** The runtime asks + the authority's `Check` at the method `POST /_codefly/installations`. The + authority routes that method to its dedicated step verifier and never to + ordinary checking, and the check is never advisory, under + `hop/ENFORCEMENT=warn` too. The verifier holds the credential to this + module's delivered principal, the installation and its revision, the + delivered generation, and the current pending attempt. The runtime then + requires the body and the carriers to name exactly what was verified: the + organization, installation and revision. It also requires a consent + operation and a single scope, `installations:install` on that installation. A carrier + parsed alone is never enough. +- **`Installation` is what was verified**: `ID`, `OrganizationID`, `Revision`, + and `OperationID`, the consent operation the step was issued under (the + verified task id), which a host retry of the same attempt keeps. The rest of + the consent receipt (its intent, the delivered generation and the offer + digest) is the authority's to check, through the step's signed binding and + its current state. Nothing else is passed on. +- **The step holds nothing.** It is given no Gateway (`ViewerFromContext` + errors inside it), and the setup credential is never handed to it or + forwarded. A pending installation is active nowhere, so there is nothing to + act under until it is. +- **One attempt runs once per process.** Concurrent deliveries of one + (installation, revision, consent operation) wait for the first, and an + acknowledgement is answered from memory for the most recent 1024 attempts. + A repeat is still verified by the authority first, so a remembered 204 is + never the answer to an attempt that no longer stands. A refusal and a + failure are not remembered, so the host's retry runs the step again. The + memory is per process: across a restart or another replica the step runs + again, so it must converge. +- **Answers.** 204 is done. 409 `{"reason": …}` ends this delivery: + - `RefuseInstall`'s public reason; + - the authority refusing the credential (PermissionDenied, InvalidArgument, + Unauthenticated, NotFound, or a refusal answered beside the context), with + one generic reason, the authority's own being logged; + - a verified step that names another installation; + - a solution that declares no step (with no step, nothing is ever + acknowledged). + + A 409 does not end the installation: a fresh claim or a fresh consent may + issue another credential. 401 means no single credential was carried. 400 + means a malformed body or carriers. 503 covers everything the host should + retry, the installation staying pending: the attempt changed or its + credential is outside its window (FailedPrecondition), the call was cancelled + or timed out, the authority failed or is unreachable, or the step failed. A + step's own error text never reaches the answer. Verification is admission, + not a reservation: the authority's activation fence still refuses a revoke + or redelivery that races the step. + ## Configuration On boot `Serve` calls `codefly.Init(ctx)`, which loads Codefly's injected diff --git a/authority_test.go b/authority_test.go index 52008fb..d0844eb 100644 --- a/authority_test.go +++ b/authority_test.go @@ -36,6 +36,9 @@ type interfaceAuthority struct { // method no row matches is refused with the authority's reason, or, on an // advisory check, answered beside the context. held []string + // step, when set, answers a Check at the install step's method: the + // authority's dedicated step verifier. + step func(*authorityv1.CheckRequest) (*authorityv1.CheckResponse, error) } // edgeContext is the person's Work Context the edge sends the page. @@ -60,8 +63,14 @@ const robinChild = "robin-child-for-the-page" func (a *interfaceAuthority) Check(_ context.Context, r *authorityv1.CheckRequest) (*authorityv1.CheckResponse, error) { a.mu.Lock() a.checked = append(a.checked, proto.Clone(r).(*authorityv1.CheckRequest)) - held := a.held + held, step := a.held, a.step a.mu.Unlock() + if r.GetMethod() == installationStepMethod { + if step == nil { + return nil, status.Error(codes.PermissionDenied, "not this installation's setup credential") + } + return step(r) + } if r.GetToken() == malformedContext { return nil, status.Error(codes.InvalidArgument, "the Work Context is not a token the authority can read") } diff --git a/go.mod b/go.mod index bf2de59..d2af607 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.27.3 require ( connectrpc.com/connect v1.21.0 - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261027012935-f41cc2baa2ec + github.com/codefly-dev/authority/libraries/authority-client/go v0.1.1-0.20261029092314-21ce6d9284ed github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027012935-f41cc2baa2ec github.com/codefly-dev/core v0.19.7-0.20261026182446-518714d046af github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 diff --git a/go.sum b/go.sum index 82f5f51..4ccb878 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261027012935-f41cc2baa2ec h1:gjLHn5hsteMDx8TeNj+KPBsa06nLwzAEhyR2IfC3JWs= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261027012935-f41cc2baa2ec/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= +github.com/codefly-dev/authority/libraries/authority-client/go v0.1.1-0.20261029092314-21ce6d9284ed h1:6dZKwVVpyiCIg25j32K6WGO72F8yHUKHvNbmYAt8Pq0= +github.com/codefly-dev/authority/libraries/authority-client/go v0.1.1-0.20261029092314-21ce6d9284ed/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027012935-f41cc2baa2ec h1:QhncdaWaMERAGpZthlSXjKroXNl/L8llPtBMX+e5w/A= github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027012935-f41cc2baa2ec/go.mod h1:NqCbvIWbkbCzSpUUYQjP8Jv+LpFnDRbVhPdAj67DHhE= github.com/codefly-dev/core v0.19.7-0.20261026182446-518714d046af h1:JLTecMs4Oja+fl51chLyGw262S4yz7jzEEiFxoq7Viw= diff --git a/installation.go b/installation.go new file mode 100644 index 0000000..5958079 --- /dev/null +++ b/installation.go @@ -0,0 +1,347 @@ +package solution + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "github.com/codefly-dev/sdk-go/workcontext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// The install step. The host records an installation pending, and its +// delivery posts the step here, carrying a setup-only credential the +// authority issued for that one pending attempt. The installation becomes +// active only once this answers 204 for it. +// +// Nothing here is decided from the request. The authority verifies the step +// (its Check, at installationStepMethod, which routes to its dedicated step +// verifier and never to ordinary checking): the credential is addressed to +// this module's delivered principal, sealed to the installation and revision, +// bound to the delivered generation, and still the current pending attempt. +// This runtime then requires the body to name exactly what was verified. +// +// The step is given no Gateway. The installation is pending, so nothing it +// holds is active yet, and the setup credential grants delivery of the step +// and nothing else: it is never handed to the step and never forwarded. + +// Installation is one pending installation of this solution, as the authority +// verified the step for it. +type Installation struct { + ID string + OrganizationID string + Revision uint64 + // OperationID is the consent operation the step was issued under (the + // verified task id). It names the attempt, not the whole consent receipt, + // whose intent, delivered generation and digest the authority checks. A + // host retry of the same attempt carries the same operation. + OperationID string +} + +// ErrInstallRefused marks an intentional install refusal. Use RefuseInstall +// to attach a public reason; other errors, including wrapping diagnostics, +// never become public response text. +var ErrInstallRefused = errors.New("installation refused") + +// RefuseInstall returns an error wrapping ErrInstallRefused with an explicitly +// public reason. The reason is limited to 256 printable ASCII bytes; other +// characters become spaces. Wrapping this error preserves only that reason in +// the response, not the wrapper's diagnostics. +func RefuseInstall(reason string) error { + return &installRefusal{reason: publicReason(reason, ErrInstallRefused.Error())} +} + +type installRefusal struct{ reason string } + +func (e *installRefusal) Error() string { return e.reason } +func (*installRefusal) Unwrap() error { return ErrInstallRefused } + +// OnInstall registers the install step, before Serve. The step must converge: +// the host retries after a transient failure, a restart or a lost answer, and +// another replica may run it for the same installation. Within this Server a +// concurrent duplicate waits for the first call, and an acknowledgement is +// answered from memory for the most recent 1024 attempts; a refusal or a +// transient failure is not remembered, so a retry runs the step again. +// ViewerFromContext returns an error inside the step. Chainable. +// +// A solution that declares no step is not installable: the step is refused +// rather than acknowledged, because an acknowledgement is what activates the +// installation, and nothing here set it up. +func (s *Server) OnInstall(step func(context.Context, Installation) error) *Server { + s.installStep = step + return s +} + +const ( + installationPath = "/_codefly/installations" + // installationStepMethod is what the authority's Check is asked at for an + // install step: its InstallationStepHTTPMethod, the one method it routes to + // the step verifier. + installationStepMethod = "POST " + installationPath + installationMaxBytes = 64 << 10 + installationFailure = "installation step failed" +) + +// handleInstallation is mounted only by serve. The order is the boundary: +// carriers and body first, then the authority, then the comparison, and only +// then the ledger, so a remembered acknowledgement is never answered to a +// request the authority did not verify for that very attempt. +func (s *Server) handleInstallation(w http.ResponseWriter, r *http.Request) { + tokens := r.Header.Values(workcontext.HeaderName) + if len(tokens) != 1 || tokens[0] == "" { + http.Error(w, "one installation step credential required", http.StatusUnauthorized) + return + } + installation, err := decodeInstallation(w, r) + if err != nil { + log.Printf("solution %q: install step refused: %v", s.manifest.ID, err) + http.Error(w, "invalid installation", http.StatusBadRequest) + return + } + verified, outcome := s.host.installationStep(r.Context(), tokens[0]) + if verified == nil { + outcome.write(w) + return + } + if err := sameInstallation(verified, s.host.principal, installation); err != nil { + // Terminal: the host built this delivery from one record, and a retry + // of it carries the same disagreement. + log.Printf("solution %q: install step refused: %v", s.manifest.ID, err) + installationOutcome{status: http.StatusConflict, reason: "the installation step does not name the installation the authority verified"}.write(w) + return + } + installation.OperationID = verified.GetTaskId() + s.installOnce(r.Context(), installation).write(w) +} + +// decodeInstallation bounds the entire body, including whitespace after the +// object, and accepts exactly one JSON object with exactly the three fields +// the authority verifies, whose installation the carriers beside the +// credential must name too. Any other member, a scope among them, is refused: +// the step credential verifies nothing else, so nothing else is read. +func decodeInstallation(w http.ResponseWriter, r *http.Request) (Installation, error) { + var body struct { + ID string `json:"installation_id"` + OrganizationID string `json:"organization_id"` + Revision uint64 `json:"revision"` + } + reader := http.MaxBytesReader(w, r.Body, installationMaxBytes) + defer func() { _ = reader.Close() }() + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&body); err != nil { + return Installation{}, err + } + if err := decoder.Decode(new(any)); err != io.EOF { + return Installation{}, errors.New("expected one installation object") + } + if body.ID == "" || body.OrganizationID == "" || body.Revision == 0 { + return Installation{}, errors.New("installation_id, organization_id and a positive revision are required") + } + ids, revisions := r.Header.Values(workcontext.InstallationIDHeaderName), r.Header.Values(workcontext.InstallationRevisionHeaderName) + if len(ids) != 1 || ids[0] != body.ID || len(revisions) != 1 || revisions[0] != strconv.FormatUint(body.Revision, 10) { + return Installation{}, fmt.Errorf("the %s and %s carriers must each name the body's installation once", + workcontext.InstallationIDHeaderName, workcontext.InstallationRevisionHeaderName) + } + return Installation{ID: body.ID, OrganizationID: body.OrganizationID, Revision: body.Revision}, nil +} + +// installationStep asks the authority to verify an install step's credential. +// Never advisory: hop/ENFORCEMENT=warn relaxes a route check, and this is not +// one. A nil context comes with the outcome to answer. +func (h *hostAuthority) installationStep(ctx context.Context, token string) (*authorityv1.WorkContextV1, installationOutcome) { + ctx, cancel := context.WithTimeout(ctx, authorityCallTimeout) + defer cancel() + checked, err := h.authority.Check(ctx, &authorityv1.CheckRequest{Token: token, Audience: h.principal, Method: installationStepMethod}) + switch status.Code(err) { + case codes.OK: + if checked.GetContext() == nil || checked.GetRefusal() != "" { + // A refusal beside a context is advice, and this check is never + // advisory: it is a refusal. + log.Printf("the authority answered an install step with refusal %q (context present: %t)", checked.GetRefusal(), checked.GetContext() != nil) + return nil, installationOutcome{status: http.StatusConflict, reason: installationStepRefused} + } + return checked.GetContext(), installationOutcome{} + case codes.PermissionDenied, codes.InvalidArgument, codes.Unauthenticated, codes.NotFound: + // The authority refuses this credential for this attempt. That ends + // this delivery, not the installation: a fresh claim or a fresh + // consent may be issued another. The reason crossing into the host's + // job history is generic; the authority's own is logged here. + log.Printf("the authority refuses an install step: %v", status.Convert(err).Message()) + return nil, installationOutcome{status: http.StatusConflict, reason: installationStepRefused} + } + // Not now (the attempt changed, or the credential is outside its delivery + // window), cancelled, or no answer: the installation stays pending and is + // retried. + log.Printf("the authority's verification of an install step did not complete: %v", err) + return nil, installationOutcome{status: http.StatusServiceUnavailable} +} + +// installationStepRefused is the one reason an authority refusal of a step +// answers with. +const installationStepRefused = "the authority refuses this installation step" + +// sameInstallation requires the verified step to be exactly the one the body +// names, at this module, carrying only the step's own scope. The authority +// checks all of it already; this is the receiver's half the authority's +// verifier asks for, done against what it answered rather than assumed. +func sameInstallation(wc *authorityv1.WorkContextV1, principal string, installation Installation) error { + scopes := wc.GetAuthorityScopes() + switch { + case wc.GetAudience() != principal: + return fmt.Errorf("the step is addressed to %q, not this module (%q)", wc.GetAudience(), principal) + case wc.GetOrganizationId() != installation.OrganizationID || wc.GetTenantId() != installation.OrganizationID: + return fmt.Errorf("the step was verified in organization %q, not %q", wc.GetOrganizationId(), installation.OrganizationID) + case wc.GetSeal().GetInstallationId() != installation.ID || wc.GetSeal().GetInstallationRevision() != installation.Revision: + return fmt.Errorf("the step is sealed to installation %q at revision %d, not %q at %d", + wc.GetSeal().GetInstallationId(), wc.GetSeal().GetInstallationRevision(), installation.ID, installation.Revision) + case wc.GetTaskId() == "": + return errors.New("the verified step names no consent operation") + case len(wc.GetActorChain()) != 0: + return errors.New("the verified step carries an actor chain") + case len(scopes) != 1 || scopes[0].GetResourceKind() != "installations" || + len(scopes[0].GetActions()) != 1 || scopes[0].GetActions()[0] != "install" || + len(scopes[0].GetResourceIds()) != 1 || scopes[0].GetResourceIds()[0] != installation.ID: + return errors.New("the verified step holds more than installing this installation") + } + return nil +} + +type installationKey struct { + id string + revision uint64 + operation string +} + +type installationOutcome struct { + status int + reason string +} + +type installationResult struct { + done chan struct{} + installationOutcome +} + +// The outcome cache is bounded. Only an acknowledgement is kept: a refusal or a +// transient failure is forgotten once its waiters have it, so the host's retry +// — including an administrator's Retry after fixing the cause, which keeps the +// consent operation — runs the step again. Completed outcomes are evicted oldest first +// past maxInstallOutcomes: the step must converge, so a repeat after eviction +// runs it again rather than answering from memory. Steps in flight are never +// evicted; past maxInstallsInFlight a new one is answered unavailable, which +// the host retries. +const ( + maxInstallOutcomes = 1024 + maxInstallsInFlight = 64 +) + +// installOnce is entered only after the authority verified this very attempt. +func (s *Server) installOnce(ctx context.Context, installation Installation) installationOutcome { + key := installationKey{id: installation.ID, revision: installation.Revision, operation: installation.OperationID} + s.installMu.Lock() + if previous := s.installations[key]; previous != nil { + s.installMu.Unlock() + select { + case <-previous.done: + return previous.installationOutcome + case <-ctx.Done(): + return installationOutcome{status: http.StatusServiceUnavailable} + } + } + if s.installations == nil { + s.installations = make(map[installationKey]*installationResult) + } + if len(s.installations)-len(s.installCompleted) >= maxInstallsInFlight { + s.installMu.Unlock() + return installationOutcome{status: http.StatusServiceUnavailable} + } + result := &installationResult{done: make(chan struct{})} + s.installations[key] = result + s.installMu.Unlock() + + // The channel publishes the immutable outcome to all waiters. A panicking + // step is recorded as a failure too, so no duplicate waits forever. + result.installationOutcome = s.runInstall(ctx, installation) + s.installMu.Lock() + if result.status != http.StatusNoContent { + delete(s.installations, key) + } else { + s.installCompleted = append(s.installCompleted, key) + for len(s.installCompleted) > maxInstallOutcomes { + delete(s.installations, s.installCompleted[0]) + s.installCompleted = s.installCompleted[1:] + } + } + s.installMu.Unlock() + close(result.done) + return result.installationOutcome +} + +func (s *Server) runInstall(ctx context.Context, installation Installation) (outcome installationOutcome) { + outcome.status = http.StatusServiceUnavailable + defer func() { + if recover() != nil { + outcome = installationOutcome{status: http.StatusServiceUnavailable} + } + }() + if s.installStep == nil { + return installationOutcome{status: http.StatusConflict, reason: "this solution declares no install step"} + } + err := s.installStep(ctx, installation) + if err == nil { + return installationOutcome{status: http.StatusNoContent} + } + var refusal *installRefusal + if errors.As(err, &refusal) && refusal != nil { + return installationOutcome{status: http.StatusConflict, reason: refusal.reason} + } + if errors.Is(err, ErrInstallRefused) { + return installationOutcome{status: http.StatusConflict, reason: ErrInstallRefused.Error()} + } + log.Printf("solution %q: install step for %s failed: %v", s.manifest.ID, installation.ID, err) + return outcome +} + +func (outcome installationOutcome) write(w http.ResponseWriter) { + switch outcome.status { + case http.StatusNoContent: + w.WriteHeader(http.StatusNoContent) + case http.StatusConflict: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(struct { + Reason string `json:"reason"` + }{Reason: outcome.reason}) + default: + http.Error(w, installationFailure, http.StatusServiceUnavailable) + } +} + +// publicReason bounds a reason that crosses into the host's job history: 256 +// printable ASCII bytes, other characters becoming spaces, and fallback when +// nothing printable is left. +func publicReason(reason, fallback string) string { + var safe strings.Builder + for _, r := range reason { + if safe.Len() == 256 { + break + } + if r < ' ' || r > '~' { + r = ' ' + } + safe.WriteByte(byte(r)) + } + if public := strings.TrimSpace(safe.String()); public != "" { + return public + } + return fallback +} diff --git a/installation_test.go b/installation_test.go new file mode 100644 index 0000000..2bda7e3 --- /dev/null +++ b/installation_test.go @@ -0,0 +1,316 @@ +package solution + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "github.com/codefly-dev/sdk-go/workcontext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// stepCredential is the setup-only credential the authority issued for one +// pending attempt: installation inst-1 at revision 3 in org-1, consent operation +// operation-1, addressed to the page. +const stepCredential = "installation-step-credential" + +// verifiedStep is what the authority's step verifier answers for +// stepCredential, edited by each case that needs it to disagree. +func verifiedStep(edit func(*authorityv1.WorkContextV1)) func(*authorityv1.CheckRequest) (*authorityv1.CheckResponse, error) { + return func(r *authorityv1.CheckRequest) (*authorityv1.CheckResponse, error) { + if r.GetToken() != stepCredential { + return nil, status.Error(codes.Unauthenticated, "the credential does not verify") + } + org := "org-1" + wc := &authorityv1.WorkContextV1{ + Audience: r.GetAudience(), TenantId: org, OrganizationId: &org, OwnerPrincipalId: "installation-agent", TaskId: "operation-1", + Seal: &authorityv1.WorkSealV1{InstallationId: "inst-1", InstallationRevision: 3}, + AuthorityScopes: []*authorityv1.WorkScopeV1{{ResourceKind: "installations", Actions: []string{"install"}, ResourceIds: []string{"inst-1"}}}, + } + if edit != nil { + edit(wc) + } + return &authorityv1.CheckResponse{Context: wc}, nil + } +} + +func stepRequest(credentials ...string) *http.Request { + r := httptest.NewRequest(http.MethodPost, installationPath, + strings.NewReader(`{"installation_id":"inst-1","organization_id":"org-1","revision":3}`)) + for _, credential := range credentials { + r.Header.Add(workcontext.HeaderName, credential) + } + r.Header.Set(workcontext.InstallationIDHeaderName, "inst-1") + r.Header.Set(workcontext.InstallationRevisionHeaderName, "3") + return r +} + +// installServer is a Server under the stand-in authority whose step records +// every installation it runs. +func installServer(t *testing.T) (*Server, *interfaceAuthority, *[]Installation) { + t.Helper() + s, stand := authorityServer(t) + stand.step = verifiedStep(nil) + var mu sync.Mutex + var ran []Installation + s.OnInstall(func(_ context.Context, installation Installation) error { + mu.Lock() + defer mu.Unlock() + ran = append(ran, installation) + return nil + }) + return s, stand, &ran +} + +func install(s *Server, r *http.Request) *httptest.ResponseRecorder { + answer := httptest.NewRecorder() + s.handleInstallation(answer, r) + return answer +} + +// The step runs only once the authority's step verifier has verified the +// attempt, and runs with exactly what it verified, consent operation included. The check +// is never advisory, even under hop/ENFORCEMENT=warn, because it is not a +// route check. +func TestAnInstallStepRunsOnlyWhatTheAuthorityVerified(t *testing.T) { + s, stand, ran := installServer(t) + s.host.advisory = true + if answer := install(s, stepRequest(stepCredential)); answer.Code != http.StatusNoContent { + t.Fatalf("a verified step answered %d: %s", answer.Code, answer.Body) + } + want := Installation{ID: "inst-1", OrganizationID: "org-1", Revision: 3, OperationID: "operation-1"} + if len(*ran) != 1 || (*ran)[0] != want { + t.Fatalf("the step ran with %+v, want once with %+v", *ran, want) + } + checks := stand.checks() + if len(checks) != 1 || checks[0].GetMethod() != "POST /_codefly/installations" || checks[0].GetAudience() != "page" || + checks[0].GetToken() != stepCredential || checks[0].GetAdvisory() { + t.Fatalf("the authority was asked %v, want one non-advisory check of the credential at the step's method", checks) + } +} + +// Every refusal is answered before the step runs, and an attempt the authority +// did not verify for exactly this installation is never acknowledged. +func TestAnInstallStepTheAuthorityDoesNotVerifyNeverRuns(t *testing.T) { + for name, c := range map[string]struct { + request func() *http.Request + step func(*authorityv1.CheckRequest) (*authorityv1.CheckResponse, error) + status int + }{ + "no credential": {request: func() *http.Request { return stepRequest() }, status: http.StatusUnauthorized}, + "two credentials": {request: func() *http.Request { return stepRequest(stepCredential, stepCredential) }, status: http.StatusUnauthorized}, + "a credential that fails": {request: func() *http.Request { return stepRequest("forged") }, status: http.StatusConflict}, + "no installation carriers": {request: func() *http.Request { + r := stepRequest(stepCredential) + r.Header.Del(workcontext.InstallationIDHeaderName) + return r + }, status: http.StatusBadRequest}, + "carriers naming another revision": {request: func() *http.Request { + r := stepRequest(stepCredential) + r.Header.Set(workcontext.InstallationRevisionHeaderName, "2") + return r + }, status: http.StatusBadRequest}, + "a body carrying a scope": {request: func() *http.Request { + r := stepRequest(stepCredential) + r.Body = io.NopCloser(strings.NewReader(`{"installation_id":"inst-1","organization_id":"org-1","revision":3,"scope":[{"resourceKind":"pages","actions":["read"]}]}`)) + return r + }, status: http.StatusBadRequest}, + "a body with an unknown field": {request: func() *http.Request { + r := stepRequest(stepCredential) + r.Body = io.NopCloser(strings.NewReader(`{"installation_id":"inst-1","organization_id":"org-1","revision":3,"activate":true}`)) + return r + }, status: http.StatusBadRequest}, + "the authority refuses the attempt": {step: func(*authorityv1.CheckRequest) (*authorityv1.CheckResponse, error) { + return nil, status.Error(codes.PermissionDenied, "not this installation's setup credential") + }, status: http.StatusConflict}, + "a refusal beside the context": {step: func(r *authorityv1.CheckRequest) (*authorityv1.CheckResponse, error) { + answer, _ := verifiedStep(nil)(r) + answer.Refusal = "not this installation's setup credential" + return answer, nil + }, status: http.StatusConflict}, + "the delivery was cancelled": {step: func(*authorityv1.CheckRequest) (*authorityv1.CheckResponse, error) { + return nil, status.Error(codes.Canceled, "cancelled") + }, status: http.StatusServiceUnavailable}, + "the attempt changed": {step: func(*authorityv1.CheckRequest) (*authorityv1.CheckResponse, error) { + return nil, status.Error(codes.FailedPrecondition, "installation step attempt changed") + }, status: http.StatusServiceUnavailable}, + "the authority is unreachable": {step: func(*authorityv1.CheckRequest) (*authorityv1.CheckResponse, error) { + return nil, status.Error(codes.Unavailable, "down") + }, status: http.StatusServiceUnavailable}, + "sealed to another installation": {step: verifiedStep(func(wc *authorityv1.WorkContextV1) { wc.Seal.InstallationId = "inst-2" }), status: http.StatusConflict}, + "sealed to another revision": {step: verifiedStep(func(wc *authorityv1.WorkContextV1) { wc.Seal.InstallationRevision = 4 }), status: http.StatusConflict}, + "verified in another org": {step: verifiedStep(func(wc *authorityv1.WorkContextV1) { + other := "org-2" + wc.OrganizationId = &other + }), status: http.StatusConflict}, + "addressed to another module": {step: verifiedStep(func(wc *authorityv1.WorkContextV1) { wc.Audience = "documents" }), status: http.StatusConflict}, + "no consent operation": {step: verifiedStep(func(wc *authorityv1.WorkContextV1) { wc.TaskId = "" }), status: http.StatusConflict}, + "an ordinary capability": {step: verifiedStep(func(wc *authorityv1.WorkContextV1) { + wc.AuthorityScopes = append(wc.AuthorityScopes, &authorityv1.WorkScopeV1{ResourceKind: "pages", Actions: []string{"read"}}) + }), status: http.StatusConflict}, + } { + t.Run(name, func(t *testing.T) { + s, stand, ran := installServer(t) + if c.step != nil { + stand.step = c.step + } + request := stepRequest(stepCredential) + if c.request != nil { + request = c.request() + } + answer := install(s, request) + if answer.Code != c.status { + t.Errorf("answered %d (%s), want %d", answer.Code, strings.TrimSpace(answer.Body.String()), c.status) + } + if strings.Contains(answer.Body.String(), "setup credential") { + t.Errorf("the authority's own reason reached the host's job history: %s", answer.Body) + } + if len(*ran) != 0 { + t.Errorf("the step ran: %+v", *ran) + } + }) + } +} + +// One attempt runs once: a repeat is answered from memory, but only after the +// authority verified the repeat too, so a remembered acknowledgement is never +// an answer to an attempt that no longer stands. Another consent operation for the same +// installation is another attempt. +func TestAnInstallAttemptRunsOnceAndARepeatIsStillVerified(t *testing.T) { + s, stand, ran := installServer(t) + for range 2 { + if answer := install(s, stepRequest(stepCredential)); answer.Code != http.StatusNoContent { + t.Fatalf("answered %d", answer.Code) + } + } + if len(*ran) != 1 { + t.Fatalf("one attempt ran the step %d times", len(*ran)) + } + stand.step = func(*authorityv1.CheckRequest) (*authorityv1.CheckResponse, error) { + return nil, status.Error(codes.FailedPrecondition, "installation has no current consented pending attempt") + } + if answer := install(s, stepRequest(stepCredential)); answer.Code != http.StatusServiceUnavailable { + t.Fatalf("a remembered attempt the authority no longer verifies answered %d", answer.Code) + } + stand.step = verifiedStep(func(wc *authorityv1.WorkContextV1) { wc.TaskId = "operation-2" }) + if answer := install(s, stepRequest(stepCredential)); answer.Code != http.StatusNoContent || len(*ran) != 2 || (*ran)[1].OperationID != "operation-2" { + t.Fatalf("another consent operation answered %d and ran %+v", answer.Code, *ran) + } +} + +// Concurrent deliveries of one attempt run the step once and every one of them +// gets its answer. +func TestConcurrentDeliveriesOfOneAttemptRunTheStepOnce(t *testing.T) { + s, stand := authorityServer(t) + stand.step = verifiedStep(nil) + var runs atomic.Int32 + started, release := make(chan struct{}, 4), make(chan struct{}) + s.OnInstall(func(context.Context, Installation) error { + runs.Add(1) + started <- struct{}{} + <-release + return nil + }) + var wg sync.WaitGroup + answers := make([]int, 4) + for i := range answers { + wg.Add(1) + go func() { + defer wg.Done() + answers[i] = install(s, stepRequest(stepCredential)).Code + }() + } + // The first delivery is inside the step; the others reach it and wait. + <-started + close(release) + wg.Wait() + if runs.Load() != 1 { + t.Errorf("the step ran %d times", runs.Load()) + } + for _, code := range answers { + if code != http.StatusNoContent { + t.Errorf("a delivery answered %d", code) + } + } +} + +// A solution that declares no step is not installable: an acknowledgement is +// what activates the installation, and nothing set it up. +func TestWithoutADeclaredStepNothingIsAcknowledged(t *testing.T) { + s, stand := authorityServer(t) + stand.step = verifiedStep(nil) + answer := install(s, stepRequest(stepCredential)) + if answer.Code != http.StatusConflict || !strings.Contains(answer.Body.String(), "declares no install step") { + t.Fatalf("a solution with no step answered %d: %s", answer.Code, answer.Body) + } +} + +// A refusal carries only its public reason and is not remembered, so the +// host's retry runs the step again; any other failure is retryable and says +// nothing about why. +func TestARefusalIsPublicAndRetriedAndAFailureSaysNothing(t *testing.T) { + s, stand := authorityServer(t) + stand.step = verifiedStep(nil) + answers := []error{ + RefuseInstall("the workspace quota is exhausted\n"), + errors.New("dial tcp 10.0.0.7:5432: connection refused"), + nil, + } + var ranWithViewer error + s.OnInstall(func(ctx context.Context, _ Installation) error { + _, ranWithViewer = ViewerFromContext(ctx) + next := answers[0] + answers = answers[1:] + return next + }) + refused := install(s, stepRequest(stepCredential)) + var reason struct{ Reason string } + if refused.Code != http.StatusConflict || json.Unmarshal(refused.Body.Bytes(), &reason) != nil || reason.Reason != "the workspace quota is exhausted" { + t.Fatalf("a refusal answered %d: %s", refused.Code, refused.Body) + } + failed := install(s, stepRequest(stepCredential)) + if failed.Code != http.StatusServiceUnavailable || strings.Contains(failed.Body.String(), "10.0.0.7") { + t.Fatalf("a failure answered %d: %s", failed.Code, failed.Body) + } + if answer := install(s, stepRequest(stepCredential)); answer.Code != http.StatusNoContent { + t.Fatalf("the retry after a refusal and a failure answered %d", answer.Code) + } + if ranWithViewer == nil { + t.Error("the step had a viewer gateway: a pending installation holds nothing a step could act under") + } +} + +// The step is served on the listener a boot serves, where the host's delivery +// posts it. +func TestTheInstallStepIsServedByTheBoot(t *testing.T) { + server := New(Manifest{ID: testSolutionID}) + var ran atomic.Int32 + server.OnInstall(func(context.Context, Installation) error { ran.Add(1); return nil }) + solution := boot(t, server) + solution.authority.mu.Lock() + solution.authority.step = verifiedStep(nil) + solution.authority.mu.Unlock() + request := stepRequest(stepCredential) + served, err := http.NewRequest(http.MethodPost, solution.base+installationPath, request.Body) + if err != nil { + t.Fatal(err) + } + served.Header = request.Header + answer, err := solution.client.Do(served) + if err != nil { + t.Fatal(err) + } + _ = answer.Body.Close() + if answer.StatusCode != http.StatusNoContent || ran.Load() != 1 { + t.Fatalf("the served step answered %d and ran %d times", answer.StatusCode, ran.Load()) + } +} diff --git a/solution.go b/solution.go index b3a6d61..9f4a303 100644 --- a/solution.go +++ b/solution.go @@ -155,6 +155,13 @@ type Server struct { mcp *mcpSurface mcpRedeclared bool cfg config + // installStep is declared before Serve, like the handlers (OnInstall). + // installations remembers each attempt's outcome under installMu + // (installation.go). + installStep func(context.Context, Installation) error + installMu sync.Mutex + installations map[installationKey]*installationResult + installCompleted []installationKey // host is the authority interface the composition names, dialled at boot. host *hostAuthority // declaredContract is the contract the author declared (Contract); @@ -583,6 +590,8 @@ func (s *Server) serve(ctx context.Context, ln net.Listener) error { // presence document names; this process does not report its liveness // anywhere, and has no way to make itself present by answering. mux.HandleFunc(HealthPath, s.handleHealth) + // The install step, which the authority verifies before anything runs. + mux.HandleFunc("POST "+installationPath, s.handleInstallation) if err := s.mountOperations(mux); err != nil { return err }