From 25a306927b4cf69f9569e616fe9aab8fa58d9fed Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:51:16 -0400 Subject: [PATCH 1/3] feat: the install step, verified by the authority's step verifier The host delivers a pending installation's setup to POST /_codefly/installations, as its existing gateway delivery does. The runtime served nothing there since e80369a deleted the saas-mode handler, which trusted a parsed carrier, acknowledged a nil step and let the step act under the carried capability. Now the authority verifies the step: the runtime asks Check at exactly POST /_codefly/installations, which the authority routes to its dedicated installation-step verifier, never advisory. The body and the installation carriers must name exactly the organization, installation and revision verified, with a consent receipt and only installations:install on that installation. The step receives those facts (Installation, now with Receipt) and no Gateway; a solution with no OnInstall is answered 409, never 204. One (installation, revision, receipt) runs once per process, and a repeat is verified before a remembered acknowledgement answers it. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 12 +- CHANGELOG.md | 19 +++ README.md | 51 ++++++- authority_test.go | 11 +- installation.go | 343 +++++++++++++++++++++++++++++++++++++++++++ installation_test.go | 299 +++++++++++++++++++++++++++++++++++++ solution.go | 9 ++ 7 files changed, 738 insertions(+), 6 deletions(-) create mode 100644 installation.go create mode 100644 installation_test.go 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..c7ac0d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # 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`, as its existing delivery already does. + 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 receipt and only + `installations:install` on that installation. +- `Installation` is the verified facts, with the new `Receipt`. The body's + `scope` is not passed on. +- The step is given no Gateway, and a solution with no step is answered 409, + never 204. +- One (installation, revision, receipt) 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..02ee380 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,52 @@ 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 `{installation_id, organization_id, revision, scope}`. + +- **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 receipt + 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 `Receipt`, the consent operation the step was issued under, which a host + retry of the same attempt keeps. The body's `scope` is decoded and dropped: + the step credential verifies none of it. +- **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, receipt) 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": …}` is terminal: `RefuseInstall`'s + public reason, the authority's refusal of the attempt, a verified step that + names another installation, or a solution that declares no step (with no + step, nothing is ever acknowledged). 401 means the credential did not + verify. 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, the authority is unreachable, or + the step failed. A step's own error text never reaches the answer. + ## 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/installation.go b/installation.go new file mode 100644 index 0000000..2f4edc7 --- /dev/null +++ b/installation.go @@ -0,0 +1,343 @@ +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 + // Receipt is the consent operation the step was issued under. A host retry + // of the same attempt carries the same receipt. + Receipt 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.Receipt = 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, whose installation the +// carriers beside the credential must name too. The host also sends the +// installation's scope; it is decoded and dropped, because the step credential +// verifies none of it. +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"` + Scope []workContextScope `json:"scope"` + } + 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 { + log.Printf("the authority verified an install step and answered no context") + return nil, installationOutcome{status: http.StatusServiceUnavailable} + } + return checked.GetContext(), installationOutcome{} + case codes.PermissionDenied, codes.InvalidArgument, codes.NotFound: + // The authority refuses this attempt by name, and a retry of it is + // refused the same way. + return nil, installationOutcome{status: http.StatusConflict, reason: publicReason(status.Convert(err).Message(), ErrInstallRefused.Error())} + case codes.Unauthenticated: + // The credential does not verify; the host's next attempt is issued + // a fresh one. + log.Printf("the authority does not verify an install step's credential: %v", status.Convert(err).Message()) + return nil, installationOutcome{status: http.StatusUnauthorized} + } + // Not now (the attempt changed, or the credential is outside its delivery + // window), 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} +} + +// 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 receipt") + 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 + receipt 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 +// receipt — 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, receipt: installation.Receipt} + 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}) + case http.StatusUnauthorized: + http.Error(w, "the authority does not verify this installation step", http.StatusUnauthorized) + 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..fae0d17 --- /dev/null +++ b/installation_test.go @@ -0,0 +1,299 @@ +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 receipt +// receipt-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: "receipt-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,"scope":[{"resourceKind":"pages","actions":["read"]}]}`)) + 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, receipt 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, Receipt: "receipt-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.StatusUnauthorized}, + "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 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}, + "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 receipt": {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() + } + if answer := install(s, request); answer.Code != c.status { + t.Errorf("answered %d (%s), want %d", answer.Code, strings.TrimSpace(answer.Body.String()), c.status) + } + 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 receipt 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 = "receipt-2" }) + if answer := install(s, stepRequest(stepCredential)); answer.Code != http.StatusNoContent || len(*ran) != 2 || (*ran)[1].Receipt != "receipt-2" { + t.Fatalf("another receipt 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 } From fdd8380aadf4d1834ca097a242eeedd2f407ee55 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:02:50 -0400 Subject: [PATCH 2/3] fix: the install step's body is exactly three fields; refusals answer 409 generically Agreed with Authority10882 and Root10879: - The body is exactly {installation_id, organization_id, revision}. A scope, or any other member, is refused 400; the step credential verifies nothing else. - A refusal answered beside the verified context is a refusal, since this check is never advisory. - Unauthenticated joins PermissionDenied/InvalidArgument/NotFound as 409, with one generic reason. The authority's own reason is logged and never crosses into the host's job history. Cancelled and timed-out calls stay 503. - Installation.Receipt is renamed OperationID: the verified task id is the consent operation, not the whole receipt, whose intent, generation and digest the authority checks. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 13 ++++---- README.md | 43 +++++++++++++++++---------- installation.go | 70 +++++++++++++++++++++++--------------------- installation_test.go | 43 +++++++++++++++++++-------- 4 files changed, 103 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ac0d1..aa56574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,20 @@ - **`OnInstall`, `Installation`, `RefuseInstall`, `ErrInstallRefused`** are back, additive to the current API. The host delivers a pending installation's setup - to `POST /_codefly/installations`, as its existing delivery already does. + 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 receipt and only + and revision it verified, with a consent operation and only `installations:install` on that installation. -- `Installation` is the verified facts, with the new `Receipt`. The body's - `scope` is not passed on. +- `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, receipt) runs once per process. A repeat is +- 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. diff --git a/README.md b/README.md index 02ee380..ae2f27a 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,8 @@ 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 `{installation_id, organization_id, revision, scope}`. +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 @@ -198,33 +199,45 @@ body is `{installation_id, organization_id, revision, scope}`. 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 receipt - and a single scope, `installations:install` on that installation. A carrier + 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 `Receipt`, the consent operation the step was issued under, which a host - retry of the same attempt keeps. The body's `scope` is decoded and dropped: - the step credential verifies none of it. + 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, receipt) wait for the first, and an + (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": …}` is terminal: `RefuseInstall`'s - public reason, the authority's refusal of the attempt, a verified step that - names another installation, or a solution that declares no step (with no - step, nothing is ever acknowledged). 401 means the credential did not - verify. 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, the authority is unreachable, or - the step failed. A step's own error text never reaches the answer. +- **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 diff --git a/installation.go b/installation.go index 2f4edc7..5958079 100644 --- a/installation.go +++ b/installation.go @@ -39,9 +39,11 @@ type Installation struct { ID string OrganizationID string Revision uint64 - // Receipt is the consent operation the step was issued under. A host retry - // of the same attempt carries the same receipt. - Receipt string + // 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 @@ -116,21 +118,20 @@ func (s *Server) handleInstallation(w http.ResponseWriter, r *http.Request) { installationOutcome{status: http.StatusConflict, reason: "the installation step does not name the installation the authority verified"}.write(w) return } - installation.Receipt = verified.GetTaskId() + 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, whose installation the -// carriers beside the credential must name too. The host also sends the -// installation's scope; it is decoded and dropped, because the step credential -// verifies none of it. +// 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"` - Scope []workContextScope `json:"scope"` + 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() }() @@ -162,27 +163,32 @@ func (h *hostAuthority) installationStep(ctx context.Context, token string) (*au 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 { - log.Printf("the authority verified an install step and answered no context") - return nil, installationOutcome{status: http.StatusServiceUnavailable} + 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.NotFound: - // The authority refuses this attempt by name, and a retry of it is - // refused the same way. - return nil, installationOutcome{status: http.StatusConflict, reason: publicReason(status.Convert(err).Message(), ErrInstallRefused.Error())} - case codes.Unauthenticated: - // The credential does not verify; the host's next attempt is issued - // a fresh one. - log.Printf("the authority does not verify an install step's credential: %v", status.Convert(err).Message()) - return nil, installationOutcome{status: http.StatusUnauthorized} + 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), or no answer: the installation stays pending and is retried. + // 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 @@ -198,7 +204,7 @@ func sameInstallation(wc *authorityv1.WorkContextV1, principal string, installat 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 receipt") + 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" || @@ -210,9 +216,9 @@ func sameInstallation(wc *authorityv1.WorkContextV1, principal string, installat } type installationKey struct { - id string - revision uint64 - receipt string + id string + revision uint64 + operation string } type installationOutcome struct { @@ -228,7 +234,7 @@ type installationResult struct { // 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 -// receipt — runs the step again. Completed outcomes are evicted oldest first +// 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 @@ -240,7 +246,7 @@ const ( // 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, receipt: installation.Receipt} + key := installationKey{id: installation.ID, revision: installation.Revision, operation: installation.OperationID} s.installMu.Lock() if previous := s.installations[key]; previous != nil { s.installMu.Unlock() @@ -315,8 +321,6 @@ func (outcome installationOutcome) write(w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(struct { Reason string `json:"reason"` }{Reason: outcome.reason}) - case http.StatusUnauthorized: - http.Error(w, "the authority does not verify this installation step", http.StatusUnauthorized) default: http.Error(w, installationFailure, http.StatusServiceUnavailable) } diff --git a/installation_test.go b/installation_test.go index fae0d17..2bda7e3 100644 --- a/installation_test.go +++ b/installation_test.go @@ -19,8 +19,8 @@ import ( ) // stepCredential is the setup-only credential the authority issued for one -// pending attempt: installation inst-1 at revision 3 in org-1, consent receipt -// receipt-1, addressed to the page. +// 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 @@ -32,7 +32,7 @@ func verifiedStep(edit func(*authorityv1.WorkContextV1)) func(*authorityv1.Check } org := "org-1" wc := &authorityv1.WorkContextV1{ - Audience: r.GetAudience(), TenantId: org, OrganizationId: &org, OwnerPrincipalId: "installation-agent", TaskId: "receipt-1", + 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"}}}, } @@ -45,7 +45,7 @@ func verifiedStep(edit func(*authorityv1.WorkContextV1)) func(*authorityv1.Check func stepRequest(credentials ...string) *http.Request { r := httptest.NewRequest(http.MethodPost, installationPath, - strings.NewReader(`{"installation_id":"inst-1","organization_id":"org-1","revision":3,"scope":[{"resourceKind":"pages","actions":["read"]}]}`)) + strings.NewReader(`{"installation_id":"inst-1","organization_id":"org-1","revision":3}`)) for _, credential := range credentials { r.Header.Add(workcontext.HeaderName, credential) } @@ -78,7 +78,7 @@ func install(s *Server, r *http.Request) *httptest.ResponseRecorder { } // The step runs only once the authority's step verifier has verified the -// attempt, and runs with exactly what it verified, receipt included. The check +// 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) { @@ -87,7 +87,7 @@ func TestAnInstallStepRunsOnlyWhatTheAuthorityVerified(t *testing.T) { 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, Receipt: "receipt-1"} + 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) } @@ -108,7 +108,7 @@ func TestAnInstallStepTheAuthorityDoesNotVerifyNeverRuns(t *testing.T) { }{ "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.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) @@ -119,6 +119,11 @@ func TestAnInstallStepTheAuthorityDoesNotVerifyNeverRuns(t *testing.T) { 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}`)) @@ -127,6 +132,14 @@ func TestAnInstallStepTheAuthorityDoesNotVerifyNeverRuns(t *testing.T) { "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}, @@ -140,7 +153,7 @@ func TestAnInstallStepTheAuthorityDoesNotVerifyNeverRuns(t *testing.T) { wc.OrganizationId = &other }), status: http.StatusConflict}, "addressed to another module": {step: verifiedStep(func(wc *authorityv1.WorkContextV1) { wc.Audience = "documents" }), status: http.StatusConflict}, - "no consent receipt": {step: verifiedStep(func(wc *authorityv1.WorkContextV1) { wc.TaskId = "" }), 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}, @@ -154,9 +167,13 @@ func TestAnInstallStepTheAuthorityDoesNotVerifyNeverRuns(t *testing.T) { if c.request != nil { request = c.request() } - if answer := install(s, request); answer.Code != c.status { + 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) } @@ -166,7 +183,7 @@ func TestAnInstallStepTheAuthorityDoesNotVerifyNeverRuns(t *testing.T) { // 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 receipt for the same +// 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) @@ -184,9 +201,9 @@ func TestAnInstallAttemptRunsOnceAndARepeatIsStillVerified(t *testing.T) { 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 = "receipt-2" }) - if answer := install(s, stepRequest(stepCredential)); answer.Code != http.StatusNoContent || len(*ran) != 2 || (*ran)[1].Receipt != "receipt-2" { - t.Fatalf("another receipt answered %d and ran %+v", answer.Code, *ran) + 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) } } From e6809f53797760ddf5e0934384f7371007379d84 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:25:59 -0400 Subject: [PATCH 3/3] deps: select authority-client at 21ce6d9, the generated installation-step handoff Authority's installation-step source and its maintained generation are pushed at codefly-dev/authority 21ce6d9 (tree 86e128ec). The installation-step receiver checks setup steps through AuthorityService.Check at the reserved POST /_codefly/installations method; this selects the generated client that documents that reserved Check, so the runtime builds against the client generated from the source it is qualified with. No new RPC or field is used, and hop stays at its existing pin. Co-Authored-By: Claude Opus 5.5 (1M context) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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=