console: a run service starts and follows its own runs of the commands it was granted (PRO-6214)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+1153
−76
internal/console/apiruns.go+179 −26internal/console/apiruns_service_test.go+795 −0internal/console/apirunsfollow.go+52 −35internal/console/authcli.go+16 −5internal/console/svcroutes_test.go+49 −9internal/host/runevents.go+6 −1internal/host/runevents_service_test.go+32 −0internal/runs/runs.go+24 −0
diff --git a/internal/console/apiruns.go b/internal/console/apiruns.go| index 19e6921a2..798d6093d 100644 |
| --- a/internal/console/apiruns.go |
| +++ b/internal/console/apiruns.go |
| @@ -11,6 +11,7 @@ import ( |
| "log" |
| "mime/multipart" |
| "net/http" |
| + netmail "net/mail" |
| "os" |
| "time" |
| @@ -19,8 +20,114 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/runs" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| +// The run handlers' refusal codes (service runs spec §3): every refusal a |
| +// run handler writes carries one beside its sentence, so a caller with no |
| +// person reading the answer can act on it. The shared gates before the |
| +// handler (not signed in, the role mismatch, the membership 404) keep their |
| +// bodies exactly as every other door gives them. |
| +const ( |
| + runCodeNotFound = "not_found" |
| + runCodeHostClaim = "host_claim" |
| + runCodeRateLimited = "rate_limited" |
| + runCodeUnavailable = "unavailable" |
| + runCodeRecordFailed = "record_failed" |
| + runCodeReadFailed = "read_failed" |
| + runCodeNotRunnable = "not_runnable" |
| + runCodeMoving = "moving" |
| + runCodeRunOpen = "run_open" |
| + runCodeMalformed = "malformed" |
| + runCodeInvalidRequest = "invalid_request" |
| + runCodeFilesTooLarge = "files_too_large" |
| + runCodeImportUncommitted = "import_uncommitted" |
| + runCodeCommandNotGranted = "command_not_granted" |
| + runCodeOnBehalfOfInvalid = "on_behalf_of_invalid" |
| + runCodeRunStarted = "run_started" |
| + runCodeRunEnded = "run_ended" |
| + runCodeRunNotFinished = "run_not_finished" |
| + runCodeLastSeqMismatch = "last_seq_mismatch" |
| +) |
| + |
| +// runRefusalCodes is every code above, the one list the docs and the tests |
| +// read. |
| +var runRefusalCodes = []string{ |
| + runCodeNotFound, runCodeHostClaim, runCodeRateLimited, runCodeUnavailable, |
| + runCodeRecordFailed, runCodeReadFailed, runCodeNotRunnable, runCodeMoving, |
| + runCodeRunOpen, runCodeMalformed, runCodeInvalidRequest, runCodeFilesTooLarge, |
| + runCodeImportUncommitted, runCodeCommandNotGranted, runCodeOnBehalfOfInvalid, |
| + runCodeRunStarted, runCodeRunEnded, runCodeRunNotFinished, runCodeLastSeqMismatch, |
| +} |
| + |
| +// runCaller reports whether p may use the run doors at all: a person (whose |
| +// membership the handler checks next), or a run service with a credential |
| +// id to own its runs by. |
| +func runCaller(p Principal) bool { |
| + switch p.Kind { |
| + case PrincipalUser: |
| + return true |
| + case PrincipalService: |
| + return p.Role == svcrole.Run && p.CredentialID != "" |
| + } |
| + return false |
| +} |
| + |
| +// callerScope names a caller for every comparison between callers: the |
| +// principal's KIND and its stable identity, so a person's address can never |
| +// equal a credential's id. "" for a principal with no identity, which |
| +// matches nothing. |
| +func callerScope(p Principal) string { |
| + switch { |
| + case p.Kind == PrincipalService && p.CredentialID != "": |
| + return "svc:" + p.CredentialID |
| + case p.Kind == PrincipalUser && p.Address != "": |
| + return "user:" + p.Address |
| + } |
| + return "" |
| +} |
| + |
| +// runVisibleTo reports whether p may see and act on req. A person sees every |
| +// run on an account they are a member of (the gate has checked that); a |
| +// service sees only the runs its own credential started, by the credential's |
| +// id, never its name, so a name minted again after a revoke inherits |
| +// nothing. |
| +func (s *Server) runVisibleTo(p Principal, req runs.Request) bool { |
| + switch p.Kind { |
| + case PrincipalUser: |
| + return true |
| + case PrincipalService: |
| + return req.ByCredential != "" && callerScope(p) == "svc:"+req.ByCredential |
| + } |
| + return false |
| +} |
| + |
| +// maxOnBehalfOf is the longest on_behalf_of an address may be. |
| +const maxOnBehalfOf = 254 |
| + |
| +const onBehalfOfPersonMsg = "on_behalf_of is for service credentials. a person runs as themselves" |
| + |
| +const onBehalfOfShapeMsg = "on_behalf_of must be an email address of at most 254 bytes" |
| + |
| +// onBehalfOfRefusal is "" when meta's on_behalf_of may stand: empty, or an |
| +// address of the right shape sent by a run service. Its shape is checked, |
| +// never its membership: it is recorded on the service's word and grants |
| +// nothing. |
| +func onBehalfOfRefusal(p Principal, v string) string { |
| + switch { |
| + case v == "": |
| + return "" |
| + case p.Kind != PrincipalService: |
| + return onBehalfOfPersonMsg |
| + case len(v) > maxOnBehalfOf || !ValidAddress(v): |
| + return onBehalfOfShapeMsg |
| + } |
| + if parsed, err := netmail.ParseAddress(v); err != nil || parsed.Address != v || parsed.Name != "" { |
| + return onBehalfOfShapeMsg |
| + } |
| + return "" |
| +} |
| + |
| // claimRunUnsupportedMsg is every run route's answer on a host-claim |
| // console (index item 12, RUN-C42). |
| const claimRunUnsupportedMsg = "carlos run is not available on this console yet. it needs the host-claim launch protocol" |
| @@ -32,6 +139,25 @@ func runOpenMsg(host string, run provision.RunState) string { |
| return fmt.Sprintf("%s already has a run open: %s, started by %s", host, run.ID, run.By) |
| } |
| +// runOpenRedactedMsg is refusal 7 for a service whose open run is not its |
| +// own: neither that run's id nor who started it. |
| +func runOpenRedactedMsg(host string) string { |
| + return fmt.Sprintf("%s already has a run open. try again once it has finished", host) |
| +} |
| + |
| +// runOpenRefusal is refusal 7 as p may read it. A service is told the open |
| +// run's id and requester only when the run is its own; a run whose request |
| +// cannot be read is not shown to be its own. |
| +func (s *Server) runOpenRefusal(ctx context.Context, p Principal, sqid, app, host string, run provision.RunState) string { |
| + if p.Kind == PrincipalUser { |
| + return runOpenMsg(host, run) |
| + } |
| + if req, err := runs.ReadRequest(ctx, s.cfg.Releases, sqid, app, run.ID); err == nil && s.runVisibleTo(p, *req) { |
| + return runOpenMsg(host, run) |
| + } |
| + return runOpenRedactedMsg(host) |
| +} |
| + |
| // runMovingMsg is refusal 6 (RUN-C23), at the door and at admission. |
| func runMovingMsg(host string) string { |
| return fmt.Sprintf("%s is moving. run the command once the move has finished", host) |
| @@ -50,6 +176,16 @@ const runMalformedMsg = "the run request is malformed" |
| const runRecordFailedMsg = "could not record the run. try again" |
| +const runCommandNotGrantedMsg = "this credential may not run that command. it runs only the commands it was minted with" |
| + |
| +// runMetaRefusalCode is the code for a refusal runMetaRefusal named. |
| +func runMetaRefusalCode(msg string) string { |
| + if msg == runFilesTooLargeMsg { |
| + return runCodeFilesTooLarge |
| + } |
| + return runCodeInvalidRequest |
| +} |
| + |
| // runClassRefusal is spec §5.1 refusal 5 as a sentence, "" for a |
| // platform-run, hibernating exec instance on a platform box. |
| func runClassRefusal(rec provision.Record) string { |
| @@ -153,7 +289,8 @@ var errRunMalformed = errors.New("console: run request disagrees with its meta") |
| // refusals come in the spec's order; nothing is written before every one |
| // that needs no body has passed. |
| func (s *Server) handleAPIRunCreate(w http.ResponseWriter, r *http.Request) { |
| - if p, ok := principalFrom(r.Context()); !ok || p.Kind != PrincipalUser { |
| + p, ok := principalFrom(r.Context()) |
| + if !ok || !runCaller(p) { |
| http.NotFound(w, r) |
| return |
| } |
| @@ -162,50 +299,50 @@ func (s *Server) handleAPIRunCreate(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| if s.cfg.HostClaim { |
| - jsonError(w, http.StatusConflict, claimRunUnsupportedMsg) |
| + jsonErrorCode(w, http.StatusConflict, runCodeHostClaim, claimRunUnsupportedMsg) |
| return |
| } |
| if !s.cli.instances.allow(hashToken(bearer(r)), time.Now()) { |
| - jsonError(w, http.StatusTooManyRequests, runTooManyMsg) |
| + jsonErrorCode(w, http.StatusTooManyRequests, runCodeRateLimited, runTooManyMsg) |
| return |
| } |
| if s.cfg.Releases == nil { |
| - jsonError(w, http.StatusServiceUnavailable, runRecordFailedMsg) |
| + jsonErrorCode(w, http.StatusServiceUnavailable, runCodeUnavailable, runRecordFailedMsg) |
| return |
| } |
| ctx := r.Context() |
| host := r.PathValue("host") |
| notInstance := fmt.Sprintf("%s is not an instance of %s", host, app) |
| if !provision.ValidHostShape(host) { |
| - jsonError(w, http.StatusNotFound, notInstance) |
| + jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, notInstance) |
| return |
| } |
| rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| if errors.Is(err, os.ErrNotExist) { |
| - jsonError(w, http.StatusNotFound, notInstance) |
| + jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, notInstance) |
| return |
| } |
| if err != nil { |
| log.Printf("console: api run %s/%s/%s: read record: %v", acct.Sqid, app, host, err) |
| - jsonError(w, http.StatusBadGateway, runRecordFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg) |
| return |
| } |
| if msg := runClassRefusal(*rec); msg != "" { |
| - jsonError(w, http.StatusConflict, msg) |
| + jsonErrorCode(w, http.StatusConflict, runCodeNotRunnable, msg) |
| return |
| } |
| if rec.Move != nil { |
| - jsonError(w, http.StatusConflict, runMovingMsg(host)) |
| + jsonErrorCode(w, http.StatusConflict, runCodeMoving, runMovingMsg(host)) |
| return |
| } |
| if rec.ActiveRun.Open() { |
| - jsonError(w, http.StatusConflict, runOpenMsg(host, *rec.ActiveRun)) |
| + jsonErrorCode(w, http.StatusConflict, runCodeRunOpen, s.runOpenRefusal(ctx, p, acct.Sqid, app, host, *rec.ActiveRun)) |
| return |
| } |
| r.Body = http.MaxBytesReader(w, r.Body, runs.MaxFileBytes+runs.MetaAllowance) |
| mr, err := r.MultipartReader() |
| if err != nil { |
| - jsonError(w, http.StatusBadRequest, runMalformedMsg) |
| + jsonErrorCode(w, http.StatusBadRequest, runCodeMalformed, runMalformedMsg) |
| return |
| } |
| var meta runs.Meta |
| @@ -216,15 +353,25 @@ func (s *Server) handleAPIRunCreate(w http.ResponseWriter, r *http.Request) { |
| err = errRunMalformed |
| } |
| if err != nil { |
| - jsonError(w, http.StatusBadRequest, runMalformedMsg) |
| + jsonErrorCode(w, http.StatusBadRequest, runCodeMalformed, runMalformedMsg) |
| return |
| } |
| if msg := runMetaRefusal(meta); msg != "" { |
| - jsonError(w, http.StatusBadRequest, msg) |
| + jsonErrorCode(w, http.StatusBadRequest, runMetaRefusalCode(msg), msg) |
| + return |
| + } |
| + if msg := onBehalfOfRefusal(p, meta.OnBehalfOf); msg != "" { |
| + jsonErrorCode(w, http.StatusBadRequest, runCodeOnBehalfOfInvalid, msg) |
| + return |
| + } |
| + // A run service runs only what it was granted, refused before any |
| + // object is written. A person runs any command. |
| + if p.Kind == PrincipalService && !runs.CommandGranted(meta.Argv, p.Commands) { |
| + jsonErrorCode(w, http.StatusForbidden, runCodeCommandNotGranted, runCommandNotGrantedMsg) |
| return |
| } |
| if rec.ImportUncommitted() { |
| - jsonError(w, http.StatusConflict, runImportMsg(host)) |
| + jsonErrorCode(w, http.StatusConflict, runCodeImportUncommitted, runImportMsg(host)) |
| return |
| } |
| files, err := spoolRunFiles(mr, meta.Files) |
| @@ -236,32 +383,35 @@ func (s *Server) handleAPIRunCreate(w http.ResponseWriter, r *http.Request) { |
| var tooLarge *http.MaxBytesError |
| switch { |
| case errors.As(err, &tooLarge): |
| - jsonError(w, http.StatusRequestEntityTooLarge, runFilesTooLargeMsg) |
| + jsonErrorCode(w, http.StatusRequestEntityTooLarge, runCodeFilesTooLarge, runFilesTooLargeMsg) |
| return |
| case err != nil: |
| - jsonError(w, http.StatusBadRequest, runMalformedMsg) |
| + jsonErrorCode(w, http.StatusBadRequest, runCodeMalformed, runMalformedMsg) |
| return |
| } |
| id, err := runs.MintID() |
| if err != nil { |
| - jsonError(w, http.StatusBadGateway, runRecordFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg) |
| return |
| } |
| inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| if err != nil { |
| - jsonError(w, http.StatusBadGateway, runRecordFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg) |
| return |
| } |
| now := time.Now().UTC() |
| req := runs.Request{Schema: 1, ID: id, Account: acct.Sqid, App: app, Host: host, Incarnation: inc, |
| Argv: meta.Argv, TimeoutS: meta.TimeoutS, By: actor, RequestedAt: now} |
| + if p.Kind == PrincipalService { |
| + req.ByService, req.ByCredential, req.OnBehalfOf = p.Name, p.CredentialID, meta.OnBehalfOf |
| + } |
| for _, f := range files { |
| req.Files = append(req.Files, f.File) |
| } |
| if err := s.writeRunObjects(ctx, req, files); err != nil { |
| log.Printf("console: api run %s/%s/%s: write %s: %v", acct.Sqid, app, host, id, err) |
| _ = runs.DeleteRun(context.WithoutCancel(ctx), s.cfg.Releases, acct.Sqid, app, id) |
| - jsonError(w, http.StatusBadGateway, runRecordFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg) |
| return |
| } |
| // The pickup window opens at admission, not at the first byte: a slow |
| @@ -284,7 +434,7 @@ func (s *Server) handleAPIRunCreate(w http.ResponseWriter, r *http.Request) { |
| // needs them, and if not, the orphan sweep ages them out by |
| // request.json (Task 5). |
| log.Printf("console: api run %s/%s/%s: admit %s: %v; record unreadable: %v", acct.Sqid, app, host, id, err, rerr) |
| - jsonError(w, http.StatusBadGateway, runRecordFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg) |
| return |
| case cur.ActiveRun != nil && cur.ActiveRun.ID == id: |
| state, err = cur.ActiveRun.State, nil |
| @@ -295,16 +445,16 @@ func (s *Server) handleAPIRunCreate(w http.ResponseWriter, r *http.Request) { |
| var open *provision.RunOpenError |
| switch { |
| case errors.As(err, &open): |
| - jsonError(w, http.StatusConflict, runOpenMsg(host, open.Run)) |
| + jsonErrorCode(w, http.StatusConflict, runCodeRunOpen, s.runOpenRefusal(wctx, p, acct.Sqid, app, host, open.Run)) |
| case errors.Is(err, provision.ErrRunMoving): |
| - jsonError(w, http.StatusConflict, runMovingMsg(host)) |
| + jsonErrorCode(w, http.StatusConflict, runCodeMoving, runMovingMsg(host)) |
| case errors.Is(err, provision.ErrRunImport): |
| - jsonError(w, http.StatusConflict, runImportMsg(host)) |
| + jsonErrorCode(w, http.StatusConflict, runCodeImportUncommitted, runImportMsg(host)) |
| case errors.Is(err, os.ErrNotExist): |
| - jsonError(w, http.StatusNotFound, notInstance) |
| + jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, notInstance) |
| default: |
| log.Printf("console: api run %s/%s/%s: admit %s: %v", acct.Sqid, app, host, id, err) |
| - jsonError(w, http.StatusBadGateway, runRecordFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg) |
| } |
| return |
| } |
| @@ -390,7 +540,10 @@ func runStartDetail(req runs.Request, version string) string { |
| Argv []string `json:"argv"` |
| Files []file `json:"files"` |
| Version string `json:"version"` |
| - }{ID: req.ID, Argv: req.Argv, Version: version} |
| + // Requester is a service's run's line (runs.Request.ServiceLine), |
| + // absent for a person's: the row's actor already names them. |
| + Requester string `json:"requester,omitempty"` |
| + }{ID: req.ID, Argv: req.Argv, Version: version, Requester: req.ServiceLine()} |
| for _, f := range req.Files { |
| d.Files = append(d.Files, file{f.Name, f.Size}) |
| } |
diff --git a/internal/console/apiruns_service_test.go b/internal/console/apiruns_service_test.go| new file mode 100644 |
| index 000000000..24dabae57 |
| --- /dev/null |
| +++ b/internal/console/apiruns_service_test.go |
| @@ -0,0 +1,795 @@ |
| +package console |
| + |
| +// A run service at the run doors (service runs spec §§ 1, 2, 5): it starts |
| +// and follows its own runs of the commands it was granted, and nothing else. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "net/http" |
| + "slices" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/runs" |
| + "github.com/carlosframework/platform/internal/seeds" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +const runServiceFor = "paul@example.com" |
| + |
| +// runServiceToken mints a run credential for app in the ops account. The |
| +// generic serviceToken cannot: a run credential needs its commands. |
| +func runServiceToken(t *testing.T, st *Store, name, app string, commands [][]string) string { |
| + t.Helper() |
| + raw, err := st.NewServicePrincipalScoped(OpsAccountID, name, app, svcrole.Run, nil, commands, "paul@keymail.dev", time.Now()) |
| + if err != nil { |
| + t.Fatalf("mint run %s: %v", name, err) |
| + } |
| + return raw |
| +} |
| + |
| +// serviceMeta is a granted command, said to be for runServiceFor. |
| +func serviceMeta() runs.Meta { |
| + return runs.Meta{Argv: []string{"import", "classic", "--slug", "x"}, TimeoutS: 60, OnBehalfOf: runServiceFor} |
| +} |
| + |
| +func codeOf(t *testing.T, body string) string { |
| + t.Helper() |
| + var e struct { |
| + Code *string `json:"code"` |
| + } |
| + if err := json.Unmarshal([]byte(body), &e); err != nil { |
| + t.Fatalf("body %q: %v", body, err) |
| + } |
| + if e.Code == nil { |
| + return "" |
| + } |
| + return *e.Code |
| +} |
| + |
| +// startAs admits one run for bearer and returns its id. |
| +func (fx *runFx) startAs(t *testing.T, bearer string, meta runs.Meta) string { |
| + t.Helper() |
| + status, body := postRun(t, fx.runsURL(runTestHost), bearer, meta, nil) |
| + if status != http.StatusAccepted { |
| + t.Fatalf("create: %d %s", status, body) |
| + } |
| + var c runs.Created |
| + if err := json.Unmarshal([]byte(body), &c); err != nil { |
| + t.Fatal(err) |
| + } |
| + return c.ID |
| +} |
| + |
| +// followAs sends one follow verb ("" is the GET) on id as bearer. |
| +func (fx *runFx) followAs(t *testing.T, bearer, verb, id string) (int, string) { |
| + t.Helper() |
| + u := fx.runsURL(runTestHost) + "/" + id |
| + if verb == "" { |
| + status, _, body := rawReq(t, http.MethodGet, u, bearer, "") |
| + return status, body |
| + } |
| + status, _, body := rawReq(t, http.MethodPost, u+"/"+verb, bearer, `{"last_seq":0}`) |
| + return status, body |
| +} |
| + |
| +// unknownAnswer is what bearer is told for verb on a run id that never |
| +// existed, rewritten to name id: the answer a run bearer may not see must be |
| +// indistinguishable from it. |
| +func (fx *runFx) unknownAnswer(t *testing.T, bearer, verb, id string) (int, string) { |
| + t.Helper() |
| + never := "run_zzzzzzzzzzzzzzzzzzzzzzzzzz" |
| + status, body := fx.followAs(t, bearer, verb, never) |
| + return status, strings.ReplaceAll(body, never, id) |
| +} |
| + |
| +func (fx *runFx) noRunObjects(t *testing.T, what string) { |
| + t.Helper() |
| + if left, _ := fx.rel.List(context.Background(), runs.RunsPrefix(release.OpsAccount, "shop")); len(left) != 0 { |
| + t.Errorf("%s left run objects %q", what, left) |
| + } |
| + if rec, _ := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost); rec != nil && rec.ActiveRun != nil { |
| + t.Errorf("%s admitted a run: %+v", what, rec.ActiveRun) |
| + } |
| +} |
| + |
| +// runDoorURLs is the five run doors, filled for runTestHost and id. |
| +func (fx *runFx) runDoorURLs(id string) []struct{ method, u, body string } { |
| + base := fx.runsURL(runTestHost) |
| + return []struct{ method, u, body string }{ |
| + {http.MethodPost, base, ""}, |
| + {http.MethodGet, base + "/" + id, ""}, |
| + {http.MethodPost, base + "/" + id + "/cancel", `{}`}, |
| + {http.MethodPost, base + "/" + id + "/expire", `{}`}, |
| + {http.MethodPost, base + "/" + id + "/ack", `{"last_seq":0}`}, |
| + } |
| +} |
| + |
| +// TestRunRoleMatrix: the run doors take a run service and refuse every other |
| +// role, Admin included, with the role gate's own 403, which names the role |
| +// that opens them. |
| +func TestRunRoleMatrix(t *testing.T) { |
| + fx := runFixture(t) |
| + runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + id := "run_aaaaaaaaaaaaaaaaaaaaaaaaaa" |
| + for _, d := range fx.runDoorURLs(id) { |
| + status, _, body := rawReq(t, d.method, d.u, runner, d.body) |
| + if status == http.StatusForbidden || status == http.StatusUnauthorized { |
| + t.Errorf("run service at %s %s = %d %s, want past the role gate", d.method, d.u, status, body) |
| + } |
| + } |
| + for _, r := range svcrole.All() { |
| + if r == svcrole.Run { |
| + continue |
| + } |
| + app, rungs := "shop", []string(nil) |
| + if r == svcrole.Admin { |
| + app = "" // the widest Admin there is |
| + } |
| + if r == svcrole.Deploy { |
| + rungs = []string{"edge"} |
| + } |
| + tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "role-"+string(r), app, r, rungs, nil, "paul@keymail.dev", time.Now()) |
| + if err != nil { |
| + t.Fatalf("mint %s: %v", r, err) |
| + } |
| + raw, _ := json.Marshal(map[string]string{"error": fmt.Sprintf("this credential holds role %q; this door needs one of [run]", r)}) |
| + want := string(raw) + "\n" |
| + for _, d := range fx.runDoorURLs(id) { |
| + status, _, body := rawReq(t, d.method, d.u, tok, d.body) |
| + if status != http.StatusForbidden || body != want { |
| + t.Errorf("role %s at %s %s = %d %q, want 403 %q", r, d.method, d.u, status, body, want) |
| + } |
| + } |
| + } |
| + fx.noRunObjects(t, "the role matrix") |
| +} |
| + |
| +func TestRunServiceStartsAGrantedCommand(t *testing.T) { |
| + fx := runFixture(t) |
| + runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + status, body := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil) |
| + if status != http.StatusAccepted { |
| + t.Fatalf("create: %d %s", status, body) |
| + } |
| + var c runs.Created |
| + if err := json.Unmarshal([]byte(body), &c); err != nil || !runs.ValidID(c.ID) || c.State != provision.RunQueued { |
| + t.Fatalf("created %+v, %v", c, err) |
| + } |
| + ctx := context.Background() |
| + req, err := runs.ReadRequest(ctx, fx.rel, release.OpsAccount, "shop", c.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + p, ok := fx.st.CLIPrincipal(runner, time.Now()) |
| + if !ok { |
| + t.Fatal("the run credential does not resolve") |
| + } |
| + if req.ByService != "ci" || req.ByCredential != p.CredentialID || req.ByCredential == "" || req.OnBehalfOf != runServiceFor { |
| + t.Fatalf("request by_service=%q by_credential=%q on_behalf_of=%q, want ci, %q, %s", req.ByService, req.ByCredential, req.OnBehalfOf, p.CredentialID, runServiceFor) |
| + } |
| + if want := "service:" + release.OpsAccount + "/ci"; req.By != want { |
| + t.Errorf("request by = %q, want the audit actor %q", req.By, want) |
| + } |
| + rec, err := provision.GetRecord(ctx, fx.rel, release.OpsAccount, "shop", runTestHost) |
| + if err != nil || rec.ActiveRun == nil || rec.ActiveRun.ID != c.ID { |
| + t.Fatalf("record run %+v, %v", rec.ActiveRun, err) |
| + } |
| + // It follows its own run. |
| + if p := fx.pageAs(t, runner, c.ID); p.ID != c.ID || p.State != provision.RunQueued { |
| + t.Fatalf("own page %+v", p) |
| + } |
| + // A scheduled run names nobody. |
| + fx.transition(t, provision.RunExpire(c.ID)) |
| + meta := serviceMeta() |
| + meta.OnBehalfOf = "" |
| + id := fx.startAs(t, runner, meta) |
| + if req, err := runs.ReadRequest(ctx, fx.rel, release.OpsAccount, "shop", id); err != nil || req.OnBehalfOf != "" || req.ByService != "ci" { |
| + t.Fatalf("scheduled request %+v, %v", req, err) |
| + } |
| +} |
| + |
| +func (fx *runFx) pageAs(t *testing.T, bearer, id string) runs.Page { |
| + t.Helper() |
| + status, body := fx.followAs(t, bearer, "", id) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET page: %d %s", status, body) |
| + } |
| + var p runs.Page |
| + if err := json.Unmarshal([]byte(body), &p); err != nil { |
| + t.Fatal(err) |
| + } |
| + return p |
| +} |
| + |
| +func TestRunServiceRefusedAnUngrantedCommand(t *testing.T) { |
| + fx := runFixture(t) |
| + runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + for _, argv := range [][]string{ |
| + {"db", "shell"}, |
| + {"import"}, // a missing word |
| + {"import", "classicx"}, // a substring of a word |
| + {"Import", "classic"}, // no case folding |
| + {"sync", "import", "classic"}, |
| + } { |
| + meta := serviceMeta() |
| + meta.Argv = argv |
| + status, body := postRun(t, fx.runsURL(runTestHost), runner, meta, nil) |
| + if status != http.StatusForbidden || codeOf(t, body) != "command_not_granted" { |
| + t.Errorf("%q: %d %s, want 403 command_not_granted", argv, status, body) |
| + } |
| + } |
| + fx.noRunObjects(t, "an ungranted command") |
| + // A person runs any command. |
| + meta := okMeta() |
| + meta.Argv = []string{"db", "shell"} |
| + if status, body := postRun(t, fx.runsURL(runTestHost), fx.member, meta, nil); status != http.StatusAccepted { |
| + t.Fatalf("a person's db shell: %d %s", status, body) |
| + } |
| +} |
| + |
| +func TestRunServiceWrongAppRefused(t *testing.T) { |
| + fx := runFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + other := runServiceToken(t, fx.st, "ci", "other", importClassic) |
| + want := canonicalNotFound(t) |
| + status, h, body := postRunH(t, fx.runsURL(runTestHost), other, serviceMeta(), nil) |
| + assertNotFound(t, "another app's run service at create", want, status, h, body) |
| + for _, d := range fx.runDoorURLs("run_aaaaaaaaaaaaaaaaaaaaaaaaaa")[1:] { |
| + status, h, body := rawReq(t, d.method, d.u, other, d.body) |
| + assertNotFound(t, "another app's run service at "+d.method+" "+d.u, want, status, h, body) |
| + } |
| + fx.noRunObjects(t, "another app's run service") |
| +} |
| + |
| +func TestRunRevokedServiceRefused(t *testing.T) { |
| + fx := runFixture(t) |
| + runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + id := fx.startAs(t, runner, serviceMeta()) |
| + if err := fx.st.RevokeServicePrincipal(OpsAccountID, "ci", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The 401 a fabricated token gets, byte for byte. |
| + _, _, want := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps", "not-a-token", "") |
| + if status, got := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil); status != http.StatusUnauthorized || got != want { |
| + t.Errorf("revoked at create = %d %q, want 401 %q", status, got, want) |
| + } |
| + for _, d := range fx.runDoorURLs(id)[1:] { |
| + status, _, got := rawReq(t, d.method, d.u, runner, d.body) |
| + if status != http.StatusUnauthorized || got != want { |
| + t.Errorf("revoked at %s %s = %d %q, want 401 %q", d.method, d.u, status, got, want) |
| + } |
| + } |
| +} |
| + |
| +func TestRunServiceCannotSeeAPersonsRun(t *testing.T) { |
| + fx := runFixture(t) |
| + runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + id := fx.startRun(t) |
| + for _, verb := range []string{"", "cancel", "expire", "ack"} { |
| + wantStatus, want := fx.unknownAnswer(t, runner, verb, id) |
| + status, body := fx.followAs(t, runner, verb, id) |
| + if status != wantStatus || body != want || status != http.StatusNotFound { |
| + t.Errorf("service %q on a person's run = %d %q, want the unknown id's %d %q", verb, status, body, wantStatus, want) |
| + } |
| + } |
| + // Nothing the service sent touched the person's run. |
| + if rec, _ := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost); rec.ActiveRun == nil || rec.ActiveRun.ID != id || rec.ActiveRun.State != provision.RunQueued { |
| + t.Fatalf("record %+v", rec.ActiveRun) |
| + } |
| + // The member still sees it, and sees a service's run too. |
| + if p := fx.page(t, id, 0); p.ID != id { |
| + t.Fatalf("member page %+v", p) |
| + } |
| + fx.transition(t, provision.RunExpire(id)) |
| + sid := fx.startAs(t, runner, serviceMeta()) |
| + if p := fx.page(t, sid, 0); p.ID != sid || p.State != provision.RunQueued { |
| + t.Fatalf("member's page of the service's run %+v", p) |
| + } |
| +} |
| + |
| +func TestRunRemintedNameDoesNotInheritRuns(t *testing.T) { |
| + fx := runFixture(t) |
| + first := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + id := fx.startAs(t, first, serviceMeta()) |
| + if err := fx.st.RevokeServicePrincipal(OpsAccountID, "ci", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + second := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + for _, verb := range []string{"", "cancel", "expire", "ack"} { |
| + wantStatus, want := fx.unknownAnswer(t, second, verb, id) |
| + status, body := fx.followAs(t, second, verb, id) |
| + if status != wantStatus || body != want || status != http.StatusNotFound { |
| + t.Errorf("the reminted name's %q = %d %q, want the unknown id's %d %q", verb, status, body, wantStatus, want) |
| + } |
| + } |
| + // Rotation keeps the credential, and so its runs. |
| + third := runServiceToken(t, fx.st, "rotated", "shop", importClassic) |
| + fx.transition(t, provision.RunExpire(id)) |
| + rid := fx.startAs(t, third, serviceMeta()) |
| + rotated, err := fx.st.RotateServicePrincipal(OpsAccountID, "rotated", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if p := fx.pageAs(t, rotated, rid); p.ID != rid { |
| + t.Fatalf("the rotated credential's own run %+v", p) |
| + } |
| +} |
| + |
| +func TestRunOpenRefusalRedactedForServices(t *testing.T) { |
| + fx := runFixture(t) |
| + runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + id := fx.startRun(t) |
| + status, body := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil) |
| + if status != http.StatusConflict || codeOf(t, body) != "run_open" { |
| + t.Fatalf("service create over a person's run: %d %s", status, body) |
| + } |
| + for _, leak := range []string{id, "alice@keymail.dev", "alice"} { |
| + if strings.Contains(body, leak) { |
| + t.Errorf("redacted refusal %s carries %q", body, leak) |
| + } |
| + } |
| + // Its own open run, it is told about. |
| + fx.transition(t, provision.RunExpire(id)) |
| + own := fx.startAs(t, runner, serviceMeta()) |
| + status, body = postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil) |
| + if status != http.StatusConflict || codeOf(t, body) != "run_open" || !strings.Contains(errorOf(t, body), own) { |
| + t.Fatalf("service create over its own run: %d %s, want run_open naming %s", status, body, own) |
| + } |
| + // Another service's run is redacted too. |
| + other := runServiceToken(t, fx.st, "ci2", "shop", importClassic) |
| + status, body = postRun(t, fx.runsURL(runTestHost), other, serviceMeta(), nil) |
| + if status != http.StatusConflict || codeOf(t, body) != "run_open" || strings.Contains(body, own) || strings.Contains(body, "service:") { |
| + t.Fatalf("service create over another service's run: %d %s", status, body) |
| + } |
| + // The member is told whose it is, as the service it was. |
| + status, body = postRun(t, fx.runsURL(runTestHost), fx.member, okMeta(), nil) |
| + if want := runTestHost + " already has a run open: " + own + ", started by service:" + release.OpsAccount + "/ci"; status != http.StatusConflict || errorOf(t, body) != want { |
| + t.Fatalf("member over a service's run: %d %s, want %q", status, body, want) |
| + } |
| +} |
| + |
| +// The admission's own refusal is redacted the same way: the rival arrives |
| +// after the door's checks passed. |
| +func TestRunOpenRefusalAtAdmissionRedactedForServices(t *testing.T) { |
| + fx := runFixture(t) |
| + runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + fx.srv.cfg.Releases = &rivalStore{Store: fx.rel, t: t} |
| + status, body := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil) |
| + if status != http.StatusConflict || codeOf(t, body) != "run_open" || strings.Contains(body, "run_aaaaaaaaaaaaaaaaaaaaaaaaaa") || strings.Contains(body, "bob@keymail.dev") { |
| + t.Fatalf("%d %s", status, body) |
| + } |
| +} |
| + |
| +func TestRunPersonMaySetNoOnBehalfOf(t *testing.T) { |
| + fx := runFixture(t) |
| + meta := okMeta() |
| + meta.OnBehalfOf = "bob@keymail.dev" |
| + status, body := postRun(t, fx.runsURL(runTestHost), fx.member, meta, nil) |
| + if status != http.StatusBadRequest || codeOf(t, body) != "on_behalf_of_invalid" { |
| + t.Fatalf("%d %s, want 400 on_behalf_of_invalid", status, body) |
| + } |
| + fx.noRunObjects(t, "a person's on_behalf_of") |
| +} |
| + |
| +func TestRunOnBehalfOfShape(t *testing.T) { |
| + fx := runFixture(t) |
| + runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + long := strings.Repeat("a", 255-len("@example.com")) + "@example.com" |
| + if len(long) != 255 { |
| + t.Fatalf("long is %d bytes", len(long)) |
| + } |
| + for _, v := range []string{"paul", "paul@", "@example.com", "Paul <paul@example.com>", "paul@example.com, bob@example.com", " paul@example.com", "paul @example.com", "paul@example.com\n", long} { |
| + meta := serviceMeta() |
| + meta.OnBehalfOf = v |
| + status, body := postRun(t, fx.runsURL(runTestHost), runner, meta, nil) |
| + if status != http.StatusBadRequest || codeOf(t, body) != "on_behalf_of_invalid" { |
| + t.Errorf("%q: %d %s, want 400 on_behalf_of_invalid", v, status, body) |
| + } |
| + } |
| + fx.noRunObjects(t, "a malformed on_behalf_of") |
| + // 254 bytes is the most there may be. Membership is never asked. |
| + meta := serviceMeta() |
| + meta.OnBehalfOf = long[1:] |
| + if status, body := postRun(t, fx.runsURL(runTestHost), runner, meta, nil); status != http.StatusAccepted { |
| + t.Fatalf("254 bytes: %d %s", status, body) |
| + } |
| +} |
| + |
| +// TestRunRefusalsCarryCodes: every refusal a run handler writes has a code |
| +// in runRefusalCodes; the shared gates before the handler do not, and keep |
| +// their bodies as every other door gives them. |
| +func TestRunRefusalsCarryCodes(t *testing.T) { |
| + fx := runFixture(t) |
| + ctx := context.Background() |
| + runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + known := map[string]bool{} |
| + for _, c := range runRefusalCodes { |
| + if known[c] { |
| + t.Errorf("runRefusalCodes names %s twice", c) |
| + } |
| + known[c] = true |
| + } |
| + seen := map[string]bool{} |
| + check := func(what string, status int, body string) { |
| + t.Helper() |
| + if status < 400 { |
| + t.Errorf("%s: %d %s, want a refusal", what, status, body) |
| + return |
| + } |
| + code := codeOf(t, body) |
| + if !known[code] || errorOf(t, body) == "" { |
| + t.Errorf("%s: %d %s, want an error and a code from runRefusalCodes", what, status, body) |
| + } |
| + seen[code] = true |
| + } |
| + checkF := func(what string, f func() (int, string)) { |
| + t.Helper() |
| + status, body := f() |
| + check(what, status, body) |
| + } |
| + post := func(what, host, bearer string, meta runs.Meta) { |
| + t.Helper() |
| + status, body := postRun(t, fx.runsURL(host), bearer, meta, nil) |
| + check(what, status, body) |
| + } |
| + post("no record", "nobody.example.com", fx.member, okMeta()) |
| + post("bad host", "-bad-", fx.member, okMeta()) |
| + putShopRecord(t, fx.consoleFixture, provision.Record{Host: "static.example.com", Kind: "static"}) |
| + post("class", "static.example.com", fx.member, okMeta()) |
| + putShopRecord(t, fx.consoleFixture, provision.Record{Host: "import.example.com", Seed: &provision.SeedSource{Kind: seeds.KindImport, State: seeds.StatePlaced}}) |
| + post("import", "import.example.com", fx.member, okMeta()) |
| + post("meta", runTestHost, fx.member, runs.Meta{TimeoutS: 60}) |
| + post("files", runTestHost, fx.member, runs.Meta{Argv: []string{"a"}, TimeoutS: 60, Files: []runs.MetaFile{{Name: "a", Size: 65 << 20}}}) |
| + malformed := okMeta() |
| + malformed.Files = []runs.MetaFile{{Name: "x.csv", Size: 5}} |
| + post("malformed", runTestHost, fx.member, malformed) |
| + ungranted := serviceMeta() |
| + ungranted.Argv = []string{"db", "shell"} |
| + post("ungranted", runTestHost, runner, ungranted) |
| + person := okMeta() |
| + person.OnBehalfOf = "bob@keymail.dev" |
| + post("person on_behalf_of", runTestHost, fx.member, person) |
| + |
| + id := fx.startRun(t) |
| + post("open", runTestHost, fx.member, okMeta()) |
| + post("open, for a service", runTestHost, runner, serviceMeta()) |
| + checkF("unknown id", func() (int, string) { return fx.followAs(t, fx.member, "", "run_zzzzzzzzzzzzzzzzzzzzzzzzzz") }) |
| + checkF("not a service's run", func() (int, string) { return fx.followAs(t, runner, "", id) }) |
| + checkF("bad after", func() (int, string) { |
| + status, _, body := rawReq(t, http.MethodGet, fx.runsURL(runTestHost)+"/"+id+"?after=x", fx.member, "") |
| + return status, body |
| + }) |
| + checkF("ack before the end", func() (int, string) { return fx.followAs(t, fx.member, "ack", id) }) |
| + checkF("ack malformed", func() (int, string) { |
| + status, _, body := rawReq(t, http.MethodPost, fx.runsURL(runTestHost)+"/"+id+"/ack", fx.member, `not json`) |
| + return status, body |
| + }) |
| + fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| + checkF("expire a picked run", func() (int, string) { return fx.followAs(t, fx.member, "expire", id) }) |
| + if err := runs.WriteResult(ctx, fx.rel, release.OpsAccount, "shop", id, runs.Result{Exit: 0, LastSeq: 3}); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.transition(t, provision.RunFinish(id)) |
| + checkF("cancel an ended run", func() (int, string) { return fx.followAs(t, fx.member, "cancel", id) }) |
| + checkF("ack at the wrong seq", func() (int, string) { return fx.followAs(t, fx.member, "ack", id) }) |
| + fx.transition(t, provision.RunClear(id)) |
| + |
| + if _, err := provision.SetRecordMove(ctx, fx.rel, release.OpsAccount, "shop", runTestHost, provision.MoveRequest("uk", time.Now())); err != nil { |
| + t.Fatal(err) |
| + } |
| + post("moving", runTestHost, fx.member, okMeta()) |
| + |
| + fx.srv.cli.instances = newRateLimiter(1, cliRateWindow) |
| + fx.srv.cli.instances.allow(hashToken(fx.member), time.Now()) |
| + post("budget", runTestHost, fx.member, okMeta()) |
| + fx.srv.cli.runs = newRateLimiter(1, cliRateWindow) |
| + fx.srv.cli.runs.allow(hashToken(fx.member), time.Now()) |
| + checkF("follow budget", func() (int, string) { return fx.followAs(t, fx.member, "", id) }) |
| + fx.srv.cli = newCLILimits() |
| + fx.srv.cfg.HostClaim = true |
| + post("host claim", runTestHost, fx.member, okMeta()) |
| + checkF("host claim follow", func() (int, string) { return fx.followAs(t, fx.member, "", id) }) |
| + fx.srv.cfg.HostClaim = false |
| + |
| + for _, want := range []string{"not_found", "not_runnable", "import_uncommitted", "invalid_request", "files_too_large", "malformed", |
| + "command_not_granted", "on_behalf_of_invalid", "run_open", "run_not_finished", "run_started", "run_ended", "last_seq_mismatch", |
| + "moving", "rate_limited", "host_claim"} { |
| + if !seen[want] { |
| + t.Errorf("no refusal here carried %s; seen %v", want, seen) |
| + } |
| + } |
| + |
| + // The shared gates: no code, and the bodies every door gives. |
| + _, _, unsigned := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps", "not-a-token", "") |
| + if status, body := postRun(t, fx.runsURL(runTestHost), "not-a-token", okMeta(), nil); status != http.StatusUnauthorized || body != unsigned || strings.Contains(body, "code") { |
| + t.Errorf("401 at the run door = %d %q, want %q", status, body, unsigned) |
| + } |
| + publish := serviceToken(t, fx.st, OpsAccountID, "pub", "shop", svcrole.Publish) |
| + if status, body := postRun(t, fx.runsURL(runTestHost), publish, okMeta(), nil); status != http.StatusForbidden || strings.Contains(body, "code") { |
| + t.Errorf("role 403 at the run door = %d %q, want no code", status, body) |
| + } |
| + stranger := cliToken(t, fx.consoleFixture, "mallory@keymail.dev", "code-run-codes-stranger") |
| + status, h, body := postRunH(t, fx.runsURL(runTestHost), stranger, okMeta(), nil) |
| + assertNotFound(t, "stranger", canonicalNotFound(t), status, h, body) |
| + status, h, body = rawReq(t, http.MethodGet, fx.runsURL(runTestHost)+"/"+id, stranger, "") |
| + assertNotFound(t, "stranger follow", canonicalNotFound(t), status, h, body) |
| +} |
| + |
| +// TestRunHandlersWriteOnlyCodedRefusals reads the run handlers' source: no |
| +// refusal there is an uncoded jsonError, and every code is a runCode |
| +// constant named in runRefusalCodes. The two http.NotFound calls are the |
| +// principal check, which answers as the membership gate does. |
| +func TestRunHandlersWriteOnlyCodedRefusals(t *testing.T) { |
| + fset := token.NewFileSet() |
| + consts := map[string]string{} |
| + for _, file := range []string{"apiruns.go", "apirunsfollow.go"} { |
| + f, err := parser.ParseFile(fset, file, nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ast.Inspect(f, func(n ast.Node) bool { |
| + switch n := n.(type) { |
| + case *ast.ValueSpec: |
| + for i, name := range n.Names { |
| + if strings.HasPrefix(name.Name, "runCode") && i < len(n.Values) { |
| + if lit, ok := n.Values[i].(*ast.BasicLit); ok { |
| + consts[name.Name] = strings.Trim(lit.Value, `"`) |
| + } |
| + } |
| + } |
| + case *ast.CallExpr: |
| + id, ok := n.Fun.(*ast.Ident) |
| + if !ok { |
| + return true |
| + } |
| + switch id.Name { |
| + case "jsonError": |
| + t.Errorf("%s: an uncoded jsonError in a run handler", fset.Position(n.Pos())) |
| + case "jsonErrorCode": |
| + if len(n.Args) != 4 { |
| + t.Errorf("%s: jsonErrorCode takes (w, status, code, msg)", fset.Position(n.Pos())) |
| + return true |
| + } |
| + switch a := n.Args[2].(type) { |
| + case *ast.Ident: |
| + if !strings.HasPrefix(a.Name, "runCode") { |
| + t.Errorf("%s: code %s is not a runCode constant", fset.Position(n.Pos()), a.Name) |
| + } |
| + case *ast.CallExpr: |
| + if f, ok := a.Fun.(*ast.Ident); !ok || f.Name != "runMetaRefusalCode" { |
| + t.Errorf("%s: code is computed by something other than runMetaRefusalCode", fset.Position(n.Pos())) |
| + } |
| + default: |
| + t.Errorf("%s: code is not a runCode constant", fset.Position(n.Pos())) |
| + } |
| + } |
| + } |
| + return true |
| + }) |
| + } |
| + if len(consts) != len(runRefusalCodes) { |
| + t.Errorf("%d runCode constants, %d in runRefusalCodes", len(consts), len(runRefusalCodes)) |
| + } |
| + for name, v := range consts { |
| + if !slices.Contains(runRefusalCodes, v) { |
| + t.Errorf("%s = %q is not in runRefusalCodes", name, v) |
| + } |
| + } |
| + for _, msg := range []string{runFilesTooLargeMsg, "anything else"} { |
| + if !slices.Contains(runRefusalCodes, runMetaRefusalCode(msg)) { |
| + t.Errorf("runMetaRefusalCode(%q) is not in runRefusalCodes", msg) |
| + } |
| + } |
| +} |
| + |
| +func TestServiceRunRendersAsTheServices(t *testing.T) { |
| + fx := runFixture(t) |
| + runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + id := fx.startAs(t, runner, serviceMeta()) |
| + fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| + if err := runs.WriteResult(context.Background(), fx.rel, release.OpsAccount, "shop", id, runs.Result{Exit: 0}); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.pageAs(t, runner, id) // writes the run-end row |
| + line := "run by service ci for " + runServiceFor + " (as stated by the service)" |
| + events, _ := fx.st.Events(OpsAccountID, "shop", 20) |
| + rows := map[string]bool{} |
| + for _, e := range events { |
| + if !strings.HasPrefix(e.Action, "run-") { |
| + continue |
| + } |
| + rows[e.Action] = true |
| + if e.Address != "service:"+release.OpsAccount+"/ci" { |
| + t.Errorf("%s row's actor is %q, want the service", e.Action, e.Address) |
| + } |
| + if !strings.Contains(e.Detail, line) { |
| + t.Errorf("%s row %q does not say %q", e.Action, e.Detail, line) |
| + } |
| + if strings.Count(e.Detail, runServiceFor) != 1 { |
| + t.Errorf("%s row %q names %s outside the service's statement", e.Action, e.Detail, runServiceFor) |
| + } |
| + } |
| + if !rows["run-start"] || !rows["run-end"] { |
| + t.Fatalf("run rows %v in %+v", rows, events) |
| + } |
| + // The console's history. |
| + c := signInPaul(t, fx.consoleFixture) |
| + status, _, page := get(t, c, fx.u("/apps/shop/activity")) |
| + if status != http.StatusOK { |
| + t.Fatalf("activity: %d", status) |
| + } |
| + if !strings.Contains(page, line) { |
| + t.Errorf("the activity page does not say %q", line) |
| + } |
| + if strings.Contains(page, "<td>"+runServiceFor+"</td>") { |
| + t.Errorf("the activity page shows %s as the actor", runServiceFor) |
| + } |
| + // A scheduled run. |
| + fx.transition(t, provision.RunFinish(id)) |
| + fx.transition(t, provision.RunClear(id)) |
| + meta := serviceMeta() |
| + meta.OnBehalfOf = "" |
| + sid := fx.startAs(t, runner, meta) |
| + req, err := runs.ReadRequest(context.Background(), fx.rel, release.OpsAccount, "shop", sid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := req.ServiceLine(); got != "run by service ci (scheduled)" { |
| + t.Errorf("scheduled line %q", got) |
| + } |
| + // A person's run has no service line, and its rows are as before. |
| + if got := (runs.Request{By: "alice@keymail.dev"}).ServiceLine(); got != "" { |
| + t.Errorf("a person's run has a service line %q", got) |
| + } |
| +} |
| + |
| +// The run's incarnation is the record's when the service created it, so a |
| +// delete and create again leaves it naming the old account (the box refuses |
| +// it, RefusedIncarnation). |
| +func TestRunServiceRunIsBoundToItsIncarnation(t *testing.T) { |
| + fx := runFixture(t) |
| + runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + id := fx.startAs(t, runner, serviceMeta()) |
| + ctx := context.Background() |
| + rec, err := provision.GetRecord(ctx, fx.rel, release.OpsAccount, "shop", runTestHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req, err := runs.ReadRequest(ctx, fx.rel, release.OpsAccount, "shop", id) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| + if err != nil || req.Incarnation != inc { |
| + t.Fatalf("request incarnation %q, record's %q, %v", req.Incarnation, inc, err) |
| + } |
| + // Deleted, and created again under the same name. |
| + if err := fx.rel.Delete(ctx, provision.RecordKey(release.OpsAccount, "shop", runTestHost)); err != nil { |
| + t.Fatal(err) |
| + } |
| + putShopRecord(t, fx.consoleFixture, provision.Record{Host: runTestHost, Channel: "stable", CreatedAt: rec.CreatedAt.Add(time.Hour)}) |
| + again, _ := provision.GetRecord(ctx, fx.rel, release.OpsAccount, "shop", runTestHost) |
| + if inc2, _ := hostclaim.Incarnation(again.Account, again.App, again.Host, again.CreatedAt); inc2 == req.Incarnation { |
| + t.Fatal("the recreated instance has the run's incarnation") |
| + } |
| +} |
| + |
| +func TestRunServiceRefusedWhileMoving(t *testing.T) { |
| + fx := runFixture(t) |
| + runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + if _, err := provision.SetRecordMove(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost, provision.MoveRequest("uk", time.Now())); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, body := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil) |
| + if status != http.StatusConflict || codeOf(t, body) != "moving" || errorOf(t, body) != runTestHost+" is moving. run the command once the move has finished" { |
| + t.Fatalf("%d %s", status, body) |
| + } |
| + fx.noRunObjectsBut(t, "a moving instance") |
| +} |
| + |
| +// noRunObjectsBut is noRunObjects without the record check, for a record |
| +// that holds a move. |
| +func (fx *runFx) noRunObjectsBut(t *testing.T, what string) { |
| + t.Helper() |
| + if left, _ := fx.rel.List(context.Background(), runs.RunsPrefix(release.OpsAccount, "shop")); len(left) != 0 { |
| + t.Errorf("%s left run objects %q", what, left) |
| + } |
| +} |
| + |
| +// A service's open run holds the instance against a move, as a person's does. |
| +func TestMoveDoorRefusesWhileAServicesRunIsOpen(t *testing.T) { |
| + fx := runFixture(t) |
| + runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + id := fx.startAs(t, runner, serviceMeta()) |
| + if _, err := fx.rel.Put(context.Background(), "control/edges/uk-box.json", |
| + []byte(`{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","data_bucket":"data-uk","data_bucket_region":"eu-west-2","hosts":{}}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + u := fx.ts.URL + "/api/cli/apps/bab/shop/instances/" + runTestHost + "/move" |
| + status, _, body := rawReq(t, http.MethodPost, u, fx.member, `{"region":"uk"}`) |
| + if status != http.StatusConflict || !strings.Contains(body, "a run is open on this instance: "+id) { |
| + t.Fatalf("move over a service's run: %d %s", status, body) |
| + } |
| +} |
| + |
| +// One run per instance holds across a person and a service: at once, one is |
| +// admitted and the other refused, and the loser's objects are gone. |
| +func TestRunPersonAndServiceRaceAdmitsOne(t *testing.T) { |
| + fx := runFixture(t) |
| + runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| + var wg sync.WaitGroup |
| + statuses := make([]int, 2) |
| + bodies := make([]string, 2) |
| + for i, c := range []struct { |
| + bearer string |
| + meta runs.Meta |
| + }{{fx.member, okMeta()}, {runner, serviceMeta()}} { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + statuses[i], bodies[i] = postRun(t, fx.runsURL(runTestHost), c.bearer, c.meta, nil) |
| + }() |
| + } |
| + wg.Wait() |
| + won, lost := 0, 0 |
| + for i := range 2 { |
| + switch statuses[i] { |
| + case http.StatusAccepted: |
| + won++ |
| + case http.StatusConflict: |
| + lost++ |
| + if codeOf(t, bodies[i]) != "run_open" { |
| + t.Errorf("loser said %s", bodies[i]) |
| + } |
| + } |
| + } |
| + if won != 1 || lost != 1 { |
| + t.Fatalf("statuses %v %v", statuses, bodies) |
| + } |
| + prefix := runs.RunsPrefix(release.OpsAccount, "shop") |
| + keys, _ := fx.rel.List(context.Background(), prefix) |
| + owners := map[string]bool{} |
| + for _, k := range keys { |
| + owners[strings.SplitN(strings.TrimPrefix(k, prefix), "/", 2)[0]] = true |
| + } |
| + if len(owners) != 1 { |
| + t.Fatalf("runs with objects %v, want only the winner's", owners) |
| + } |
| +} |
| + |
| +func TestCallerScopeNamesTheKind(t *testing.T) { |
| + person := Principal{Kind: PrincipalUser, Address: "abc"} |
| + svc := Principal{Kind: PrincipalService, CredentialID: "abc"} |
| + if callerScope(person) == callerScope(svc) { |
| + t.Fatalf("a person and a credential with the same string share a scope: %q", callerScope(person)) |
| + } |
| + if callerScope(person) != "user:abc" || callerScope(svc) != "svc:abc" { |
| + t.Fatalf("scopes %q %q", callerScope(person), callerScope(svc)) |
| + } |
| + if callerScope(Principal{Kind: PrincipalService}) != "" || callerScope(Principal{Kind: PrincipalUser}) != "" { |
| + t.Fatal("a principal with no identity has a scope") |
| + } |
| + s := &Server{} |
| + if s.runVisibleTo(Principal{Kind: PrincipalService}, runs.Request{}) { |
| + t.Fatal("a service with no id sees a run with no credential") |
| + } |
| + if !s.runVisibleTo(svc, runs.Request{ByCredential: "abc"}) || s.runVisibleTo(svc, runs.Request{ByCredential: "abd"}) { |
| + t.Fatal("runVisibleTo does not key on the credential id") |
| + } |
| + if !s.runVisibleTo(person, runs.Request{ByCredential: "zzz"}) { |
| + t.Fatal("a member does not see a service's run") |
| + } |
| +} |
diff --git a/internal/console/apirunsfollow.go b/internal/console/apirunsfollow.go| index 74620c72c..8ad16d076 100644 |
| --- a/internal/console/apirunsfollow.go |
| +++ b/internal/console/apirunsfollow.go |
| @@ -20,6 +20,7 @@ const runReadFailedMsg = "could not read the run. try again" |
| // runCall is one follow route's resolved request. |
| type runCall struct { |
| + p Principal |
| actor string |
| acct *Account |
| app string |
| @@ -30,10 +31,13 @@ type runCall struct { |
| func noRunMsg(host, id string) string { return fmt.Sprintf("%s has no run %s", host, id) } |
| // runGate is the four follow routes' shared gate, in the create door's |
| -// order: a person, a member (the byte-identical 404), the host-claim |
| -// refusal, the runs budget, then a run id of the right shape. |
| +// order: a person or a run service, a member (the byte-identical 404), the |
| +// host-claim refusal, the runs budget, then a run id of the right shape. A |
| +// service then reaches only a run its own credential started: any other is |
| +// answered exactly as an unknown id is, so a run's existence does not leak. |
| func (s *Server) runGate(w http.ResponseWriter, r *http.Request) (runCall, bool) { |
| - if p, ok := principalFrom(r.Context()); !ok || p.Kind != PrincipalUser { |
| + p, ok := principalFrom(r.Context()) |
| + if !ok || !runCaller(p) { |
| http.NotFound(w, r) |
| return runCall{}, false |
| } |
| @@ -42,22 +46,33 @@ func (s *Server) runGate(w http.ResponseWriter, r *http.Request) (runCall, bool) |
| return runCall{}, false |
| } |
| if s.cfg.HostClaim { |
| - jsonError(w, http.StatusConflict, claimRunUnsupportedMsg) |
| + jsonErrorCode(w, http.StatusConflict, runCodeHostClaim, claimRunUnsupportedMsg) |
| return runCall{}, false |
| } |
| if !s.cli.runs.allow(hashToken(bearer(r)), time.Now()) { |
| - jsonError(w, http.StatusTooManyRequests, runTooManyMsg) |
| + jsonErrorCode(w, http.StatusTooManyRequests, runCodeRateLimited, runTooManyMsg) |
| return runCall{}, false |
| } |
| - c := runCall{actor: actor, acct: acct, app: app, host: r.PathValue("host"), id: r.PathValue("id")} |
| + c := runCall{p: p, actor: actor, acct: acct, app: app, host: r.PathValue("host"), id: r.PathValue("id")} |
| if s.cfg.Releases == nil { |
| - jsonError(w, http.StatusServiceUnavailable, runReadFailedMsg) |
| + jsonErrorCode(w, http.StatusServiceUnavailable, runCodeUnavailable, runReadFailedMsg) |
| return runCall{}, false |
| } |
| if !provision.ValidHostShape(c.host) || !runs.ValidID(c.id) { |
| - jsonError(w, http.StatusNotFound, noRunMsg(c.host, c.id)) |
| + jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, noRunMsg(c.host, c.id)) |
| return runCall{}, false |
| } |
| + if p.Kind == PrincipalService { |
| + req, err := runs.ReadRequest(r.Context(), s.cfg.Releases, acct.Sqid, app, c.id) |
| + switch { |
| + case err != nil && !errors.Is(err, os.ErrNotExist): |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg) |
| + return runCall{}, false |
| + case err != nil || !s.runVisibleTo(p, *req): |
| + jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, noRunMsg(c.host, c.id)) |
| + return runCall{}, false |
| + } |
| + } |
| return c, true |
| } |
| @@ -114,13 +129,13 @@ func (s *Server) handleAPIRunGet(w http.ResponseWriter, r *http.Request) { |
| if q := r.URL.Query().Get("after"); q != "" { |
| var err error |
| if after, err = strconv.ParseInt(q, 10, 64); err != nil || after < 0 { |
| - jsonError(w, http.StatusBadRequest, runMalformedMsg) |
| + jsonErrorCode(w, http.StatusBadRequest, runCodeMalformed, runMalformedMsg) |
| return |
| } |
| } |
| run, err := s.runOf(ctx, c) |
| if err != nil { |
| - jsonError(w, http.StatusBadGateway, runReadFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg) |
| return |
| } |
| if run == nil { |
| @@ -129,21 +144,21 @@ func (s *Server) handleAPIRunGet(w http.ResponseWriter, r *http.Request) { |
| found, err := s.requestOnHost(ctx, c) |
| switch { |
| case err != nil: |
| - jsonError(w, http.StatusBadGateway, runReadFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg) |
| return |
| case !found: |
| - jsonError(w, http.StatusNotFound, noRunMsg(c.host, c.id)) |
| + jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, noRunMsg(c.host, c.id)) |
| return |
| } |
| } |
| res, err := runs.ReadResult(ctx, s.cfg.Releases, c.acct.Sqid, c.app, c.id) |
| if err != nil { |
| - jsonError(w, http.StatusBadGateway, runReadFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg) |
| return |
| } |
| keys, err := runs.ListChunks(ctx, s.cfg.Releases, c.acct.Sqid, c.app, c.id, after, runs.PageChunks+1) |
| if err != nil { |
| - jsonError(w, http.StatusBadGateway, runReadFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg) |
| return |
| } |
| p := runs.Page{ID: c.id, Result: res, Chunks: []runs.Chunk{}} |
| @@ -163,7 +178,7 @@ func (s *Server) handleAPIRunGet(w http.ResponseWriter, r *http.Request) { |
| } |
| raw, _, err := s.cfg.Releases.Get(ctx, k) |
| if err != nil { |
| - jsonError(w, http.StatusBadGateway, runReadFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg) |
| return |
| } |
| if size+len(raw) > runs.PageBytes && len(p.Chunks) > 0 { |
| @@ -198,7 +213,7 @@ func (s *Server) handleAPIRunCancel(w http.ResponseWriter, r *http.Request) { |
| case provision.RunCancelled: |
| if err := s.endUnpicked(ctx, c, provision.RunCancelled); err != nil { |
| log.Printf("console: api run %s/%s/%s %s: end after cancel: %v", c.acct.Sqid, c.app, c.host, c.id, err) |
| - jsonError(w, http.StatusBadGateway, runRecordFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg) |
| return |
| } |
| case provision.RunCancel: |
| @@ -227,7 +242,7 @@ func (s *Server) handleAPIRunExpire(w http.ResponseWriter, r *http.Request) { |
| } |
| if err := s.endUnpicked(ctx, c, provision.RunExpired); err != nil { |
| log.Printf("console: api run %s/%s/%s %s: end after expire: %v", c.acct.Sqid, c.app, c.host, c.id, err) |
| - jsonError(w, http.StatusBadGateway, runRecordFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg) |
| return |
| } |
| writeJSON(w, http.StatusOK, runs.StateAnswer{State: got.ActiveRun.State}) |
| @@ -261,26 +276,26 @@ func (s *Server) endUnpicked(ctx context.Context, c runCall, state string) error |
| func (s *Server) runTransitionRefused(w http.ResponseWriter, ctx context.Context, c runCall, err error) { |
| if !errors.Is(err, provision.ErrRunConflict) && !errors.Is(err, os.ErrNotExist) { |
| log.Printf("console: api run %s/%s/%s %s: %v", c.acct.Sqid, c.app, c.host, c.id, err) |
| - jsonError(w, http.StatusBadGateway, runRecordFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg) |
| return |
| } |
| run, rerr := s.runOf(ctx, c) |
| switch { |
| case rerr != nil: |
| - jsonError(w, http.StatusBadGateway, runReadFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg) |
| case run == nil: |
| - jsonError(w, http.StatusNotFound, noRunMsg(c.host, c.id)) |
| + jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, noRunMsg(c.host, c.id)) |
| case run.Open(): |
| - jsonError(w, http.StatusConflict, fmt.Sprintf("run %s has already started", c.id)) |
| + jsonErrorCode(w, http.StatusConflict, runCodeRunStarted, fmt.Sprintf("run %s has already started", c.id)) |
| default: |
| if run.State == provision.RunCancelled || run.State == provision.RunExpired { |
| if err := s.endUnpicked(ctx, c, run.State); err != nil { |
| log.Printf("console: api run %s/%s/%s %s: end after retry: %v", c.acct.Sqid, c.app, c.host, c.id, err) |
| - jsonError(w, http.StatusBadGateway, runRecordFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg) |
| return |
| } |
| } |
| - jsonError(w, http.StatusConflict, fmt.Sprintf("run %s has already ended", c.id)) |
| + jsonErrorCode(w, http.StatusConflict, runCodeRunEnded, fmt.Sprintf("run %s has already ended", c.id)) |
| } |
| } |
| @@ -295,7 +310,7 @@ func (s *Server) handleAPIRunAck(w http.ResponseWriter, r *http.Request) { |
| } |
| var ack runs.Ack |
| if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&ack); err != nil { |
| - jsonError(w, http.StatusBadRequest, runMalformedMsg) |
| + jsonErrorCode(w, http.StatusBadRequest, runCodeMalformed, runMalformedMsg) |
| return |
| } |
| ctx, cancel := runWriteCtx(r.Context()) |
| @@ -303,33 +318,33 @@ func (s *Server) handleAPIRunAck(w http.ResponseWriter, r *http.Request) { |
| found, err := s.requestOnHost(ctx, c) |
| switch { |
| case err != nil: |
| - jsonError(w, http.StatusBadGateway, runReadFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg) |
| return |
| case !found: |
| - jsonError(w, http.StatusNotFound, noRunMsg(c.host, c.id)) |
| + jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, noRunMsg(c.host, c.id)) |
| return |
| } |
| res, err := runs.ReadResult(ctx, s.cfg.Releases, c.acct.Sqid, c.app, c.id) |
| switch { |
| case err != nil: |
| - jsonError(w, http.StatusBadGateway, runReadFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg) |
| return |
| case res == nil: |
| - jsonError(w, http.StatusConflict, fmt.Sprintf("run %s has not finished yet", c.id)) |
| + jsonErrorCode(w, http.StatusConflict, runCodeRunNotFinished, fmt.Sprintf("run %s has not finished yet", c.id)) |
| return |
| case res.LastSeq != ack.LastSeq: |
| - jsonError(w, http.StatusConflict, fmt.Sprintf("run %s ended at chunk %d, not %d", c.id, res.LastSeq, ack.LastSeq)) |
| + jsonErrorCode(w, http.StatusConflict, runCodeLastSeqMismatch, fmt.Sprintf("run %s ended at chunk %d, not %d", c.id, res.LastSeq, ack.LastSeq)) |
| return |
| } |
| run, err := s.runOf(ctx, c) |
| switch { |
| case err != nil: |
| - jsonError(w, http.StatusBadGateway, runReadFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg) |
| return |
| case run.Open(): |
| // The box has written the result and not yet moved the run to done. |
| // Its objects are the box's until it has. |
| - jsonError(w, http.StatusConflict, fmt.Sprintf("run %s has not finished yet", c.id)) |
| + jsonErrorCode(w, http.StatusConflict, runCodeRunNotFinished, fmt.Sprintf("run %s has not finished yet", c.id)) |
| return |
| } |
| s.auditRunEnd(ctx, c.acct, c.app, c.host, c.id, runEndOutcome(res), res) |
| @@ -341,7 +356,7 @@ func (s *Server) handleAPIRunAck(w http.ResponseWriter, r *http.Request) { |
| still, rerr := s.runOf(ctx, c) |
| if rerr != nil || still != nil { |
| log.Printf("console: api run %s/%s/%s %s: clear after ack: %v; record read: %v", c.acct.Sqid, c.app, c.host, c.id, cerr, rerr) |
| - jsonError(w, http.StatusBadGateway, runRecordFailedMsg) |
| + jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg) |
| return |
| } |
| } |
| @@ -385,7 +400,7 @@ func (s *Server) auditRunEnd(ctx context.Context, acct *Account, app, host, id, |
| // marker Put that succeeds for a run whose request.json is gone therefore |
| // raced a finished ack: the row was written already. Take the marker back |
| // and write nothing. |
| - by := "" |
| + by, requester := "", "" |
| switch req, err := runs.ReadRequest(ctx, s.cfg.Releases, acct.Sqid, app, id); { |
| case errors.Is(err, os.ErrNotExist): |
| if derr := s.cfg.Releases.Delete(ctx, runs.AuditedKey(acct.Sqid, app, id)); derr != nil { |
| @@ -393,13 +408,15 @@ func (s *Server) auditRunEnd(ctx context.Context, acct *Account, app, host, id, |
| } |
| return |
| case err == nil: |
| - by = req.By |
| + by, requester = req.By, req.ServiceLine() |
| } |
| d := struct { |
| ID string `json:"id"` |
| Outcome string `json:"outcome"` |
| Result *runs.Result `json:"result,omitempty"` |
| - }{id, outcome, res} |
| + // Requester is a service's run's line, as on the run-start row. |
| + Requester string `json:"requester,omitempty"` |
| + }{id, outcome, res, requester} |
| raw, _ := json.Marshal(d) |
| s.auditActivity(acct, app, by, Activity{Action: "run-end", TargetType: "instance", Target: host, Detail: string(raw)}) |
| } |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 954e47d57..63ba5c45f 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -733,11 +733,15 @@ func (s *Server) registerCLIRoutes() { |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped", svcrole.Allow(svcrole.Instance), s.handleAPIImportStopped) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/commit", svcrole.Allow(svcrole.Instance), s.handleAPIImportCommit) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/abort", svcrole.Allow(svcrole.Instance), s.handleAPIImportAbort) |
| - s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs", svcrole.Set{}, s.handleAPIRunCreate) |
| - s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}", svcrole.Set{}, s.handleAPIRunGet) |
| - s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/cancel", svcrole.Set{}, s.handleAPIRunCancel) |
| - s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/expire", svcrole.Set{}, s.handleAPIRunExpire) |
| - s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/ack", svcrole.Set{}, s.handleAPIRunAck) |
| + // The run doors (service runs spec §1): a person on the member rule, or a |
| + // run service, and no other role. Exactly, not Allow, so Admin does not |
| + // join: a credential that may provision instances cannot run commands on |
| + // them unless it was minted for that. |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs", svcrole.Exactly(svcrole.Run), s.handleAPIRunCreate) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}", svcrole.Exactly(svcrole.Run), s.handleAPIRunGet) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/cancel", svcrole.Exactly(svcrole.Run), s.handleAPIRunCancel) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/expire", svcrole.Exactly(svcrole.Run), s.handleAPIRunExpire) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/ack", svcrole.Exactly(svcrole.Run), s.handleAPIRunAck) |
| // `carlos instances retry <host>` (apiretry.go): the operate family's, as |
| // restart is. The {host} wildcard and the 404 are the instance family's. |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/retry", svcrole.Allow(svcrole.Operate), s.handleAPIInstanceRetry) |
| @@ -1099,6 +1103,13 @@ func jsonError(w http.ResponseWriter, status int, msg string) { |
| writeJSON(w, status, map[string]string{"error": msg}) |
| } |
| +// jsonErrorCode is jsonError with a stable machine-readable code beside the |
| +// sentence. The run handlers' own refusals use it (runRefusalCodes); a |
| +// shared gate never does, so its body stays the same at every door. |
| +func jsonErrorCode(w http.ResponseWriter, status int, code, msg string) { |
| + writeJSON(w, status, map[string]string{"error": msg, "code": code}) |
| +} |
| + |
| // readJSON decodes a CLI request body, insisting on a JSON content type. |
| // |
| // That insistence IS the CSRF defence for the unauthenticated endpoints: a |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 92d8e2fdb..182e00302 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -221,13 +221,10 @@ var closedDoors = []string{ |
| // same door's gate for an instance in another region and relays its |
| // rows: the same cut. |
| "POST /api/cli/apps/{account}/{app}/db/{host}/reads", |
| - // `carlos run` (run spec §2 ruling 1): a person who is a member, the same |
| - // rule as the db door. A service credential cannot run commands. |
| - "POST /api/cli/apps/{account}/{app}/instances/{host}/runs", |
| - "GET /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}", |
| - "POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/cancel", |
| - "POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/expire", |
| - "POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/ack", |
| + // The five `carlos run` doors are not here: since the service runs spec |
| + // (2026-10-24 §1, amending run spec §2 ruling 1) they take the run role |
| + // and no other, Admin included, through svcrole.Exactly. runDoors below |
| + // pins that. |
| // The build probe is what `carlos deploy`'s watch TRUSTS in place of the |
| // X-Carlos-Version header — the header being the thing that reports a |
| // release before any process has restarted. A credential that could |
| @@ -282,6 +279,49 @@ var closedDoors = []string{ |
| "POST /api/cli/apps/{account}/{app}/releases/{version}/authorize", |
| } |
| +// runDoors are the five `carlos run` doors (service runs spec §1). They are |
| +// the one family registered with svcrole.Exactly: the run role reaches them |
| +// and NO other role does, Admin included, so a credential that may provision |
| +// instances cannot run commands on them unless it was minted for that. |
| +var runDoors = []string{ |
| + "POST /api/cli/apps/{account}/{app}/instances/{host}/runs", |
| + "GET /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}", |
| + "POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/cancel", |
| + "POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/expire", |
| + "POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/ack", |
| +} |
| + |
| +// TestRunDoorsTakeOnlyTheRunRole is the route-role table's row for the run |
| +// doors, and the only doors the run role reaches. |
| +func TestRunDoorsTakeOnlyTheRunRole(t *testing.T) { |
| + s := newConsoleFixture(t).srv |
| + isRunDoor := map[string]bool{} |
| + for _, pattern := range runDoors { |
| + isRunDoor[pattern] = true |
| + set, ok := s.cliRoles[pattern] |
| + if !ok { |
| + t.Errorf("%s is not registered", pattern) |
| + continue |
| + } |
| + if got := set.Roles(); len(got) != 1 || got[0] != svcrole.Run { |
| + t.Errorf("%s takes roles %v, want exactly [run]", pattern, got) |
| + } |
| + for _, r := range svcrole.All() { |
| + if r != svcrole.Run && set.Permits(r) { |
| + t.Errorf("%s is reachable by role %q; only run may reach a run door", pattern, r) |
| + } |
| + } |
| + if set.Permits(svcrole.Admin) { |
| + t.Errorf("%s admits Admin", pattern) |
| + } |
| + } |
| + for pattern, set := range s.cliRoles { |
| + if set.Permits(svcrole.Run) && !isRunDoor[pattern] { |
| + t.Errorf("%s is reachable by the run role, which reaches the run doors only", pattern) |
| + } |
| + } |
| +} |
| + |
| // TestEveryCLIDoorNamesItsRoles is the default-deny guarantee for the |
| // /api/cli/ half. A door registered with plain mux.HandleFunc would not |
| // appear in cliRoles at all; a door registered with an accidental empty Set |
| @@ -1008,8 +1048,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 64 { |
| - t.Errorf("walked %d closed doors, want all %d (and 64 today: instance-token, three door-key doors, the routes door, instances-manifest, the app update policy, sidecars-manifest, the route-channel move, three instance update settings doors, two db replica doors, the forwarded db read, six fleets, four services, two email credentials, email pause, the build probe, the swap policy, the live tenancy read, the layout read, the activation-cancel door, nine system doors, three deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, four store-entitlement doors, two account plan doors, and the five run doors)", |
| + if seen != len(closedDoors) || seen != 59 { |
| + t.Errorf("walked %d closed doors, want all %d (and 59 today: instance-token, three door-key doors, the routes door, instances-manifest, the app update policy, sidecars-manifest, the route-channel move, three instance update settings doors, two db replica doors, the forwarded db read, six fleets, four services, two email credentials, email pause, the build probe, the swap policy, the live tenancy read, the layout read, the activation-cancel door, nine system doors, three deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, four store-entitlement doors, and two account plan doors)", |
| seen, len(closedDoors)) |
| } |
| } |
diff --git a/internal/host/runevents.go b/internal/host/runevents.go| index 338498311..71d554ada 100644 |
| --- a/internal/host/runevents.go |
| +++ b/internal/host/runevents.go |
| @@ -20,8 +20,13 @@ import ( |
| const runEventLevel = applog.LevelInfo |
| -// runStartedLine is the line for a run whose unit has started. |
| +// runStartedLine is the line for a run whose unit has started. A service's |
| +// run names the service, and the person it named only as its statement |
| +// (service runs spec §2). |
| func runStartedLine(req runs.Request) string { |
| + if line := req.ServiceLine(); line != "" { |
| + return fmt.Sprintf("run %s started: %s", req.ID, line) |
| + } |
| return fmt.Sprintf("run %s started by %s", req.ID, req.By) |
| } |
diff --git a/internal/host/runevents_service_test.go b/internal/host/runevents_service_test.go| new file mode 100644 |
| index 000000000..c88842c31 |
| --- /dev/null |
| +++ b/internal/host/runevents_service_test.go |
| @@ -0,0 +1,32 @@ |
| +package host |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/runs" |
| +) |
| + |
| +// A service's run reads as the service's in `carlos logs`: the person it |
| +// named appears only as the service's statement, never as who ran it |
| +// (service runs spec §2). |
| +func TestRunStartedLineForAServicesRun(t *testing.T) { |
| + req := runs.Request{ID: "r1", By: "service:bab/ci", ByService: "ci", ByCredential: "c0ffee", OnBehalfOf: "paul@example.com", |
| + Argv: []string{"import", "classic", "--token", "s3cret"}} |
| + got := runStartedLine(req) |
| + if want := "run r1 started: run by service ci for paul@example.com (as stated by the service)"; got != want { |
| + t.Fatalf("got %q, want %q", got, want) |
| + } |
| + if strings.Contains(got, "by paul@example.com") || strings.Contains(got, "c0ffee") { |
| + t.Fatalf("%q presents the person as the actor or shows the credential id", got) |
| + } |
| + for _, a := range req.Argv { |
| + if strings.Contains(got, a) { |
| + t.Fatalf("%q carries the argument %q", got, a) |
| + } |
| + } |
| + req.OnBehalfOf = "" |
| + if got, want := runStartedLine(req), "run r1 started: run by service ci (scheduled)"; got != want { |
| + t.Fatalf("scheduled: got %q, want %q", got, want) |
| + } |
| +} |
diff --git a/internal/runs/runs.go b/internal/runs/runs.go| index 158fdfdbc..fd402da6d 100644 |
| --- a/internal/runs/runs.go |
| +++ b/internal/runs/runs.go |
| @@ -147,6 +147,10 @@ type Meta struct { |
| Argv []string `json:"argv"` |
| TimeoutS int64 `json:"timeout_s"` |
| Files []MetaFile `json:"files"` |
| + // OnBehalfOf is the person a run service says it is running for. Only |
| + // a run service may send it; it is recorded on the service's word and |
| + // grants nothing (service runs spec §2). |
| + OnBehalfOf string `json:"on_behalf_of,omitempty"` |
| } |
| type MetaFile struct { |
| @@ -175,11 +179,31 @@ type Request struct { |
| Files []File `json:"files"` |
| By string `json:"by"` |
| RequestedAt time.Time `json:"requested_at"` |
| + // A service's run (service runs spec §2): the credential's name, for |
| + // display; its stable id, which is what decides whose run it is (a |
| + // name is reused after a revoke, an id never); and the person it said |
| + // it ran for, unverified. All three are empty for a person's run. |
| + ByService string `json:"by_service,omitempty"` |
| + ByCredential string `json:"by_credential,omitempty"` |
| + OnBehalfOf string `json:"on_behalf_of,omitempty"` |
| } |
| // Timeout is the run's timeout as a duration. |
| func (r Request) Timeout() time.Duration { return time.Duration(r.TimeoutS) * time.Second } |
| +// ServiceLine is how every surface names the requester of a service's run, |
| +// "" for a person's. The person a service names is always shown as that |
| +// service's statement, never as the one who ran it. |
| +func (r Request) ServiceLine() string { |
| + switch { |
| + case r.ByService == "": |
| + return "" |
| + case r.OnBehalfOf == "": |
| + return "run by service " + r.ByService + " (scheduled)" |
| + } |
| + return "run by service " + r.ByService + " for " + r.OnBehalfOf + " (as stated by the service)" |
| +} |
| + |
| // Result is runs/<id>/result.json (run spec §5.5), written only after the |
| // last chunk. Signal is the decimal signal number when a signal ended the |
| // command (index item 5); Detail is the member-safe wake error for a |