tracker: the board reads the forge, and the forge says who is asking
CI/CD / containment (push) Successful in 3m39s
Hanzo CI/CD / cicd (push) Failing after 1h1m51s
CI/CD / gate (push) Failing after 1h1m51s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
CI/CD / containment (push) Successful in 3m39s
Hanzo CI/CD / cicd (push) Failing after 1h1m51s
CI/CD / gate (push) Failing after 1h1m51s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The work items on /v1/tracker were a SQLite table beside a github.com feeder, while the estate files, labels and closes its issues on git.hanzo.ai. Two stores under one prefix are two answers to what the state of a piece of work is, and they disagreed the first time anyone touched the forge directly, which is every day. The forge is now the store: every read is a read OF it, every write a write TO it, and nothing here caches or mirrors a row. A board is a repository, a column is a LABEL, and a card is an issue. Reading the column off a label is what makes the board and the forge web UI the same object seen twice — relabel in either and the card moves in both. So the repository lifecycle is NOT on this surface: creating, renaming and deleting a board are forge operations under forge permissions, and a second door onto them here would be a weaker guard on the same object. Those three answer 405 naming the forge, which is a different fact from 404. Milestones are repo-scoped upstream and there is no org-level list, so the org view is a server-side fan-out over the repositories the caller can see — bounded, and failing whole rather than returning a partial rollup that reads as complete. TWO INDEPENDENT CONTROLS, because neither is trusted to be sufficient and this forge really does host private orgs. The org comes from the validated principal (principal.OrgFrom) and never from a path, query or body. Then every call is made with Forgejo Sudo as the caller's own IAM username, which DROPS PRIVILEGE to that user — measured against the live forge: the machine token reads hanzo-private/patents (200), the same token sudoed as a non-member gets 404, byte-identical to anonymous. So a bug in the first control cannot leak a private repository on its own, and a write is attributed to the HUMAN rather than to a shared bot. One credential, held in KMS at orgs/hanzo/deploy/FORGE_TRACKER_TOKEN@prod — never an env file, never a browser-side PAT. A separate secret from the universe pin token on purpose: one credential per capability, so a compromise of the tracker cannot deploy. Resolved lazily with a TTL so rotation is live without a restart, invalidated when the forge rejects it, and fail-closed at every step — an anonymous client would quietly serve public repos and read as "your board is empty" rather than "this deployment is misconfigured". The forge host is brand.Sibling of the deployment's own API host, so a white-labelled deployment cannot read another brand's forge. Also closes two tenancy defects found beside this work: plane.AgentPRIn carried an Org the agent-PR seam read off the wire and passed straight into the per-tenant store selector, so a caller on the plane could file a work item onto ANOTHER tenant's board by naming it. Its sibling on the same socket, IssueIn, has never had one. The field is gone, the handler reads cloud.Who(ctx), the caller carries the org in the ENVELOPE (which is re-checked by the same OrgOf rule as the HTTP boundary), and a reflection test now fails if an org-shaped field returns to either input. The audit trail's Home field means "a platform SuperAdmin acted inside another tenant". Its predicate was home != effective, which WAS impersonation back when a SuperAdmin was the only principal who could act outside their home org. Since membership-based org switching, any ordinary member of two orgs trips it the moment they work in their second one — telling auditors that routine work was an admin impersonation, and burying the real events in volume. The predicate is now the fact it always meant: home is the reserved admin org (authz.AdminOrg, the issuer's constant). Tests: 22 on the forge client (fail-closed with no actor, credential never in an error, bounded fan-out, pagination that terminates against an endless forge, a compile-time refusal of tenancy in the filter), 12 on the surface (cross-tenant read, no-principal refusal, CSRF-refused writes never reaching the forge, attribution of a move). The retired SQLite HTTP surface's tests go with it; the two security properties they held — the CSRF gate and the per-IAM-project store isolation, which still backs the plane doors — are re-pinned against what survives. forge/live_test.go exercises the real forge, skipped unless FORGE_LIVE_TOKEN is set, because a stub can only confirm we built what we believed and not that what we believed is true. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -119,9 +119,15 @@ type planeTracker struct{}
|
||||
func (planeTracker) CreatePR(ctx context.Context, in PRInput) (PRRef, error) {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
out, err := plane.Ask[plane.AgentPRIn, plane.AgentPROut](ctx, trackerApp, plane.TrackerAgentPR,
|
||||
// The org travels in the ENVELOPE (cloud.For), not in the body. Both spell the
|
||||
// same word here, but only the envelope is checked: the plane's identity slot
|
||||
// is read back through the same OrgOf rule as the HTTP boundary, so a call
|
||||
// crossing the plane cannot be granted an org key the boundary would refuse.
|
||||
// A body field would arrive unchecked — which is what made this a
|
||||
// cross-tenant write. Same shape as cloud.UpsertIssue's Ask.
|
||||
out, err := plane.Ask[plane.AgentPRIn, plane.AgentPROut](plane.For(ctx, in.Org), trackerApp, plane.TrackerAgentPR,
|
||||
&plane.AgentPRIn{
|
||||
Org: in.Org, Project: in.Project, Repo: in.Repo, Base: in.Base,
|
||||
Project: in.Project, Repo: in.Repo, Base: in.Base,
|
||||
Head: in.Head, Title: in.Title, Body: in.Body, Assignee: in.Assignee,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -86,8 +86,10 @@ func servePeers(t *testing.T, p *peers) {
|
||||
trackerApp := zip.New(zip.Config{AppName: "tracker", DisableStartupMessage: true})
|
||||
zip.Post[plane.AgentPRIn, plane.AgentPROut](trackerApp, "/tracker/agent-pr",
|
||||
func(ctx context.Context, in *plane.AgentPRIn) (*plane.AgentPROut, error) {
|
||||
// No in.Org: the org is the caller's plane identity, mirroring the real
|
||||
// handler (plugin/tracker/seams.go) after the cross-tenant write was closed.
|
||||
ref, err := p.tracker.CreatePR(ctx, PRInput{
|
||||
Org: in.Org, Project: in.Project, Repo: in.Repo, Base: in.Base,
|
||||
Org: zip.CallerOf(ctx).Org, Project: in.Project, Repo: in.Repo, Base: in.Base,
|
||||
Head: in.Head, Title: in.Title, Body: in.Body, Assignee: in.Assignee,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
package tracker
|
||||
|
||||
// source.go binds the tracker to the forge.
|
||||
//
|
||||
// The forge (git.hanzo.ai beside api.hanzo.ai) is the SINGLE source of truth for
|
||||
// this estate's work items. An issue is filed, labelled, assigned and closed
|
||||
// there, so that is where the tracker reads it from — not from a copy. There is
|
||||
// deliberately no mirror, no cache table and no write-through: a second store at
|
||||
// this prefix would be a second answer to "what is the state of this work", and
|
||||
// the two would drift the first time anyone touched the forge directly, which is
|
||||
// every day.
|
||||
//
|
||||
// # What maps to what
|
||||
//
|
||||
// The forge already has every noun the board needs, so nothing is invented:
|
||||
//
|
||||
// tracker project a forge REPOSITORY (its name is the key)
|
||||
// tracker issue a forge ISSUE (its per-repo number is the number)
|
||||
// board column a forge LABEL drawn from the closed `statuses` set
|
||||
// priority a forge LABEL drawn from the closed `priorities` set
|
||||
// milestone a forge MILESTONE, rolled up across the org's repos
|
||||
//
|
||||
// Reading the column off a LABEL is what makes the board and the forge the same
|
||||
// object seen twice: moving a card is a relabel, and an engineer who relabels in
|
||||
// the forge web UI has moved the card. A status column in a table here could not
|
||||
// have that property.
|
||||
//
|
||||
// # Tenancy, and the two independent controls
|
||||
//
|
||||
// The org is resolved from the VALIDATED principal (principal.OrgFrom) and never
|
||||
// from a path, a query or a body — a tenant key read from caller-supplied data is
|
||||
// a cross-tenant read the caller asserted for itself. That is control one, and it
|
||||
// is the same rule typed.go states for In fields.
|
||||
//
|
||||
// Control two is the forge's own ACL: every call is made with Sudo as the
|
||||
// requesting user (forge.Client.As), which DROPS PRIVILEGE to that user. So the
|
||||
// deployment's machine token cannot read an org the user could not read anyway,
|
||||
// and a bug in control one cannot leak a private repo on its own. The two are
|
||||
// independent, and neither is trusted to be sufficient — which matters here
|
||||
// because this forge really does host private orgs whose issues must not cross.
|
||||
//
|
||||
// The actor is X-User-Name, the IAM username, which the identity boundary strips
|
||||
// on ingress and re-mints only from validated claims (middleware_identity.go). A
|
||||
// caller cannot choose who it acts as.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/authz"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/principal"
|
||||
"github.com/hanzoai/cloud/brand"
|
||||
"github.com/hanzoai/cloud/forge"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// tokenRef is the KMS coordinate of the forge machine credential.
|
||||
//
|
||||
// KMS is the one home for a secret: not an env file (which reaches a git history
|
||||
// and a pod spec), not a browser-held PAT (which puts a forge credential in
|
||||
// reach of any script on the page), and not a per-user OAuth grant this process
|
||||
// would have to custody and rotate N times.
|
||||
//
|
||||
// Same shape as apps/platform's pinTokenRef, and a DIFFERENT secret on purpose:
|
||||
// that one may push to universe, this one reads issues. One credential per
|
||||
// capability means a compromise of the tracker cannot deploy, and revoking the
|
||||
// tracker's token does not stop releases.
|
||||
const tokenRef = "orgs/hanzo/deploy/FORGE_TRACKER_TOKEN@prod"
|
||||
|
||||
// forgeSource holds the deployment's forge client and the credential behind it.
|
||||
//
|
||||
// The client is resolved LAZILY rather than at Mount: KMS need not be reachable
|
||||
// at process start, a token rotates while the process lives, and a tracker that
|
||||
// refused to mount because KMS was slow would take the whole binary down with
|
||||
// it. It is cached because the alternative is a KMS read per board load.
|
||||
type forgeSource struct {
|
||||
mu sync.Mutex
|
||||
client *forge.Client
|
||||
host string
|
||||
fresh time.Time
|
||||
}
|
||||
|
||||
// ttl bounds how long a resolved credential is reused. A rotated token is
|
||||
// therefore live within this window without a restart, and a revoked one stops
|
||||
// working. Short enough to make rotation real, long enough that a board load is
|
||||
// not a KMS read.
|
||||
const ttl = 5 * time.Minute
|
||||
|
||||
// resolve returns a forge client authenticated with the deployment's machine
|
||||
// credential, reading it from KMS when the cached one is absent or stale.
|
||||
//
|
||||
// Fail closed at every step: no KMS client, a KMS that cannot answer, or an
|
||||
// empty secret each return an ERROR and never a client. The alternative — an
|
||||
// anonymous client — would quietly serve only public repos and read as "your
|
||||
// board is empty" rather than "this deployment is misconfigured".
|
||||
//
|
||||
// The error names the REF, never the value. A ref is a path and is safe to log.
|
||||
func (f *forgeSource) resolve(ctx context.Context, s *cloud.Service[state]) (*forge.Client, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.client != nil && time.Since(f.fresh) < ttl {
|
||||
return f.client, nil
|
||||
}
|
||||
if s.KMS == nil {
|
||||
return nil, fmt.Errorf("no KMS client mounted: cannot read %s", tokenRef)
|
||||
}
|
||||
b, err := s.KMS.GetSecret(ctx, tokenRef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", tokenRef, err)
|
||||
}
|
||||
token := strings.TrimSpace(string(b))
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("%s is empty", tokenRef)
|
||||
}
|
||||
// The forge is the sibling of this deployment's own API host, derived through
|
||||
// the ONE derivation of it. A literal "git.hanzo.ai" here is what makes a
|
||||
// white-labelled deployment read another brand's forge.
|
||||
//
|
||||
// CLOUD_FORGE_HOST overrides it for the deployment whose forge genuinely is
|
||||
// not that sibling — a developer box, or a migration running against a staging
|
||||
// forge. It is an override and not the source: unset, which is every
|
||||
// production deployment, the host is derived and cannot drift per brand.
|
||||
host := f.host
|
||||
if host == "" {
|
||||
host = strings.TrimSpace(os.Getenv("CLOUD_FORGE_HOST"))
|
||||
}
|
||||
if host == "" {
|
||||
host = brand.Sibling(s.Domain, forge.Name)
|
||||
}
|
||||
c, err := forge.New(host, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.client, f.fresh = c, time.Now()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// invalidate drops a cached credential the forge has just rejected, so the next
|
||||
// request re-reads KMS instead of replaying a revoked token for the rest of the
|
||||
// TTL.
|
||||
func (f *forgeSource) invalidate() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.client = nil
|
||||
}
|
||||
|
||||
// scopeForge resolves the two facts every forge-backed read needs: the validated
|
||||
// ORG (which tenant's work this is) and the ACTOR (whose eyes the forge should
|
||||
// answer through), returning a client already scoped to both.
|
||||
//
|
||||
// Both come from the validated principal and neither can be supplied by the
|
||||
// caller. A request with no validated org, or with no IAM username to act as,
|
||||
// gets 403 — never a client carrying the bare machine identity.
|
||||
func (o ops) scopeForge(ctx context.Context) (*forge.Client, string, error) {
|
||||
c, ok := cloud.Request(ctx)
|
||||
if !ok {
|
||||
// Off the HTTP path (a CLI LocalInvoke) there is no attested tenant and no
|
||||
// attested actor, so there is nothing to scope by. Same 403 as an
|
||||
// unauthenticated request.
|
||||
return nil, "", zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
org, ok := principal.OrgFrom(ctx)
|
||||
if !ok {
|
||||
return nil, "", zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
actor := actorOf(c)
|
||||
if actor == "" {
|
||||
return nil, "", zip.ErrForbidden("no forge identity for this principal")
|
||||
}
|
||||
cl, err := o.s.State.forge.resolve(ctx, o.s)
|
||||
if err != nil {
|
||||
o.s.Log.Error("forge credential unavailable", "err", err)
|
||||
return nil, "", zip.Errorf(http.StatusServiceUnavailable, "forge unavailable")
|
||||
}
|
||||
return cl.As(actor), org, nil
|
||||
}
|
||||
|
||||
// actorOf is the IAM username the forge should act as.
|
||||
//
|
||||
// X-User-Name is the `name` half of <owner>/<name>, stamped by the identity
|
||||
// boundary from VALIDATED claims only — it is in authorityHeaders, so a client's
|
||||
// own copy is stripped on ingress and cannot survive. It is therefore safe to
|
||||
// hand to Sudo: a caller cannot name someone else.
|
||||
//
|
||||
// It falls back to X-User-Id only when the username is absent, which is the same
|
||||
// order resolveCaller uses — the gateway path historically minted the name into
|
||||
// X-User-Id while the in-binary direct-Bearer path stamps the UUID subject.
|
||||
func actorOf(c *zip.Ctx) string {
|
||||
if n := strings.TrimSpace(c.Header(authz.HeaderUserName)); n != "" {
|
||||
return n
|
||||
}
|
||||
return strings.TrimSpace(c.User())
|
||||
}
|
||||
|
||||
// answer renders a forge error onto the wire.
|
||||
//
|
||||
// An unknown actor is 403 and says so: the user has no forge identity, which is
|
||||
// a fact about them and is fixable, unlike an empty board which is indistinguishable
|
||||
// from "no work". A rejected credential invalidates the cache and reads 503 —
|
||||
// the deployment is broken, not the request.
|
||||
func (o ops) answer(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, forge.ErrUnknownActor):
|
||||
return zip.ErrForbidden("no forge identity for this principal")
|
||||
case errors.Is(err, forge.ErrNoActor), errors.Is(err, forge.ErrNoToken):
|
||||
o.s.Log.Error("forge call made unscoped or uncredentialed", "err", err)
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "forge unavailable")
|
||||
default:
|
||||
if strings.Contains(err.Error(), "credential rejected") {
|
||||
o.s.State.forge.invalidate()
|
||||
o.s.Log.Error("forge rejected the machine credential", "ref", tokenRef)
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "forge unavailable")
|
||||
}
|
||||
o.s.Log.Error("forge read failed", "err", err)
|
||||
return zip.Errorf(http.StatusBadGateway, "forge read failed")
|
||||
}
|
||||
}
|
||||
|
||||
// ── the projections ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// A forge row rendered as the shape this surface already publishes, so the
|
||||
// shipped SPA keeps working against a different source of truth.
|
||||
|
||||
// repoProject renders a forge repository as a tracker project. The repo NAME is
|
||||
// the key: it is already unique within the org and already the thing every URL,
|
||||
// clone and issue reference addresses, so deriving a separate short key would
|
||||
// invent a second name for one object.
|
||||
func repoProject(org string, r forge.Repo) trackerProject {
|
||||
return trackerProject{
|
||||
ID: r.FullName,
|
||||
Org: org,
|
||||
Key: r.Name,
|
||||
Name: r.Name,
|
||||
}
|
||||
}
|
||||
|
||||
// forgeIssue renders a forge issue as a tracker issue.
|
||||
//
|
||||
// Status and priority are LIFTED OUT of the label set rather than sitting beside
|
||||
// it: a label that means "in_progress" is the column, so leaving it in the
|
||||
// generic label list would render it twice — once as the card's column and once
|
||||
// as a chip on the card.
|
||||
func forgeIssue(i forge.Issue) issueView {
|
||||
repo := ""
|
||||
if i.Repository != nil {
|
||||
repo = i.Repository.Name
|
||||
}
|
||||
status, priority, rest := classify(i.Labels)
|
||||
// A closed issue is done regardless of its labels: the forge's own state is
|
||||
// the stronger fact, and a card sitting in "todo" after being closed on the
|
||||
// forge is precisely the drift this design removes.
|
||||
if strings.EqualFold(i.State, "closed") {
|
||||
status = "done"
|
||||
}
|
||||
kind := "issue"
|
||||
if i.PullRequest != nil {
|
||||
kind = "pr"
|
||||
}
|
||||
assignee := ""
|
||||
if len(i.Assignees) > 0 {
|
||||
assignee = i.Assignees[0].Login
|
||||
}
|
||||
v := issueView{
|
||||
ID: fmt.Sprintf("%d", i.ID),
|
||||
Identifier: fmt.Sprintf("%s#%d", repo, i.Number),
|
||||
ProjectKey: repo,
|
||||
Number: int(i.Number),
|
||||
Kind: kind,
|
||||
// Every row on this surface now originates on the forge. `git` is the
|
||||
// contract's word for that origin (contract.go), and it is a fact rather
|
||||
// than a default.
|
||||
Source: "git",
|
||||
Repo: repo,
|
||||
Title: i.Title,
|
||||
Description: i.Body,
|
||||
Status: status,
|
||||
Priority: priority,
|
||||
Assignee: assignee,
|
||||
Labels: rest,
|
||||
CreatedAt: unix(i.Created),
|
||||
UpdatedAt: unix(i.Updated),
|
||||
}
|
||||
if i.Milestone != nil && i.Milestone.Due != "" {
|
||||
v.DueAt = unix(i.Milestone.Due)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// classify splits a forge label set into the board column, the priority, and
|
||||
// everything else. The first label matching each closed set wins; a repo that
|
||||
// carries two status labels is mis-labelled on the forge, and picking the first
|
||||
// deterministically is better than refusing to render the card.
|
||||
func classify(labels []forge.Label) (status, priority string, rest []string) {
|
||||
status, priority = "", ""
|
||||
rest = []string{}
|
||||
for _, l := range labels {
|
||||
n := strings.ToLower(strings.TrimSpace(l.Name))
|
||||
// A forge label is conventionally "status/in progress" or "in_progress";
|
||||
// normalise the separator so both spell the same column.
|
||||
n = strings.ReplaceAll(strings.TrimPrefix(n, "status/"), " ", "_")
|
||||
switch {
|
||||
case status == "" && statuses[n]:
|
||||
status = n
|
||||
case priority == "" && priorities[n]:
|
||||
priority = n
|
||||
default:
|
||||
rest = append(rest, l.Name)
|
||||
}
|
||||
}
|
||||
if status == "" {
|
||||
status = "backlog"
|
||||
}
|
||||
if priority == "" {
|
||||
priority = "none"
|
||||
}
|
||||
return status, priority, rest
|
||||
}
|
||||
|
||||
// unix converts a forge RFC3339 timestamp to unix seconds, 0 when absent or
|
||||
// unparseable — the wire shape publishes 0 as "unset" and a rendering failure
|
||||
// must not fail the read.
|
||||
func unix(s string) int64 {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
// milestoneView is one milestone in the org rollup. It carries the repo it came
|
||||
// from because the rollup spans repos and a title alone does not identify one.
|
||||
type milestoneView struct {
|
||||
ID int64 `json:"id"`
|
||||
Repo string `json:"repo"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
Open int `json:"open"`
|
||||
Closed int `json:"closed"`
|
||||
DueAt int64 `json:"dueAt,omitempty"`
|
||||
}
|
||||
|
||||
func forgeMilestone(m forge.Milestone) milestoneView {
|
||||
return milestoneView{
|
||||
ID: m.ID, Repo: m.Repo, Title: m.Title, State: m.State,
|
||||
Open: m.Open, Closed: m.Closed, DueAt: unix(m.Due),
|
||||
}
|
||||
}
|
||||
|
||||
// ── the reads ────────────────────────────────────────────────────────────────
|
||||
|
||||
// ListProjects returns the boards of your org — one per repository on the
|
||||
// deployment's forge that you can see. The key is the repository name, and it is
|
||||
// what addresses the board's issues.
|
||||
//
|
||||
// Archived repositories are omitted: they are not live work. The set is the
|
||||
// FORGE's answer for your own account, so two people in one org can legitimately
|
||||
// see different boards.
|
||||
func (o ops) forgeProjects(ctx context.Context, _ *noInput) (*projectList, error) {
|
||||
cl, org, err := o.scopeForge(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repos, err := cl.Repos(ctx, org)
|
||||
if err != nil {
|
||||
return nil, o.answer(err)
|
||||
}
|
||||
out := make(projectList, 0, len(repos))
|
||||
for _, r := range repos {
|
||||
if r.Archived {
|
||||
continue
|
||||
}
|
||||
out = append(out, repoProject(org, r))
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetProject returns one board of your org by its key — the repository name.
|
||||
// 404 when your org has no repository under that key, or when your own forge
|
||||
// account cannot see it.
|
||||
func (o ops) forgeProject(ctx context.Context, in *projectRef) (*trackerProject, error) {
|
||||
cl, org, err := o.scopeForge(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repos, err := cl.Repos(ctx, org)
|
||||
if err != nil {
|
||||
return nil, o.answer(err)
|
||||
}
|
||||
for _, r := range repos {
|
||||
// Case-insensitive like the key it replaces, so an existing link keeps
|
||||
// resolving; the forge itself treats repository names case-insensitively.
|
||||
if strings.EqualFold(r.Name, in.Key) && !r.Archived {
|
||||
v := repoProject(org, r)
|
||||
return &v, nil
|
||||
}
|
||||
}
|
||||
return nil, zip.ErrNotFound("no such project")
|
||||
}
|
||||
|
||||
// ListIssues returns one board's issues — the work items of that repository on
|
||||
// the forge, with their column, priority, assignee and labels.
|
||||
//
|
||||
// The column is a LABEL on the forge, so the board and the forge web UI are the
|
||||
// same object seen twice: relabelling in either moves the card in both. A closed
|
||||
// issue reads as done whatever its labels say.
|
||||
func (o ops) forgeIssues(ctx context.Context, in *issueQuery) (*issueList, error) {
|
||||
cl, org, err := o.scopeForge(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Status != "" && !statuses[in.Status] {
|
||||
return nil, zip.ErrBadRequest("unknown status")
|
||||
}
|
||||
if in.Kind != "" && !kinds[in.Kind] {
|
||||
return nil, zip.ErrBadRequest("unknown kind")
|
||||
}
|
||||
f := forge.IssueFilter{State: "all"}
|
||||
switch in.Kind {
|
||||
case "pr":
|
||||
f.Type = "pulls"
|
||||
case "issue":
|
||||
f.Type = "issues"
|
||||
}
|
||||
rows, err := cl.Issues(ctx, org, f)
|
||||
if err != nil {
|
||||
return nil, o.answer(err)
|
||||
}
|
||||
out := make(issueList, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
v := forgeIssue(r)
|
||||
// The board is addressed by repository, and issues-search spans the org, so
|
||||
// the repo IS the project filter. Compared case-insensitively for the same
|
||||
// reason getProject is.
|
||||
if !strings.EqualFold(v.Repo, in.Key) {
|
||||
continue
|
||||
}
|
||||
if in.Status != "" && v.Status != in.Status {
|
||||
continue
|
||||
}
|
||||
if in.Scheduled && v.DueAt == 0 && v.StartAt == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ListMilestones returns every milestone across your org's repositories, each
|
||||
// stamped with the repository it belongs to.
|
||||
//
|
||||
// The forge scopes milestones to a repository and publishes no org-level list,
|
||||
// so this is a server-side fan-out over the repositories you can see. It runs
|
||||
// here rather than in the browser because a client-side fan-out would need the
|
||||
// forge reachable from the page and a credential held there.
|
||||
func (o ops) forgeMilestones(ctx context.Context, _ *noInput) (*milestoneList, error) {
|
||||
cl, org, err := o.scopeForge(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ms, err := cl.Milestones(ctx, org)
|
||||
if err != nil {
|
||||
return nil, o.answer(err)
|
||||
}
|
||||
out := make(milestoneList, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
out = append(out, forgeMilestone(m))
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// milestoneList is the org's milestone rollup. Empty is an empty JSON array,
|
||||
// never null.
|
||||
type milestoneList []milestoneView
|
||||
|
||||
// ── the writes ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Each one is made on the forge under the caller's own Sudo actor, so the forge
|
||||
// records the HUMAN as the author and as the mover of every card. A shared bot
|
||||
// identity would make the audit trail say "the tracker did it", which is not an
|
||||
// answer to who did it.
|
||||
|
||||
// newIssue opens a work item on a board.
|
||||
type newIssue struct {
|
||||
// Key is the board — the repository name, from the path.
|
||||
Key string `json:"key"`
|
||||
// Title is required.
|
||||
Title string `json:"title"`
|
||||
// Description becomes the issue body.
|
||||
Description string `json:"description"`
|
||||
// Status is the board column to open into: backlog, todo, in_progress, done
|
||||
// or canceled. Empty opens into backlog.
|
||||
Status string `json:"status"`
|
||||
// Priority is one of none, urgent, high, medium or low.
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
// CreateIssue opens a work item on the board — an issue on that repository on
|
||||
// the deployment's forge, filed as YOU.
|
||||
//
|
||||
// The column and priority are written as LABELS, which is what makes the card
|
||||
// and the forge issue the same object: someone relabelling in the forge web UI
|
||||
// has moved your card.
|
||||
func (o ops) forgeCreateIssue(ctx context.Context, in *newIssue) (*issueView, error) {
|
||||
cl, org, err := o.scopeForge(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(in.Title) == "" {
|
||||
return nil, zip.ErrBadRequest("title required")
|
||||
}
|
||||
labels, err := columnLabels(in.Status, in.Priority)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
got, err := cl.CreateIssue(ctx, org, in.Key, forge.NewIssue{
|
||||
Title: in.Title, Body: in.Description, Labels: labels,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, o.answer(err)
|
||||
}
|
||||
v := forgeIssue(got)
|
||||
// issues-search stamps the repository on every row; the create response does
|
||||
// not, because the repo was the address. Fill it so the card knows its board.
|
||||
if v.Repo == "" {
|
||||
v.Repo, v.ProjectKey = in.Key, in.Key
|
||||
v.Identifier = fmt.Sprintf("%s#%d", in.Key, got.Number)
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// issueEdit changes a work item. Absent fields are left alone.
|
||||
type issueEdit struct {
|
||||
// Key is the board — the repository name, from the path.
|
||||
Key string `json:"key"`
|
||||
// Num is the issue number on that repository, from the path.
|
||||
Num int64 `json:"num"`
|
||||
// Title renames the work item.
|
||||
Title string `json:"title"`
|
||||
// Description rewrites the body.
|
||||
Description string `json:"description"`
|
||||
// Status moves the card to another column.
|
||||
Status string `json:"status"`
|
||||
// Priority re-prioritises it.
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
// UpdateIssue edits a work item — rename it, rewrite it, move it to another
|
||||
// column, or re-prioritise it. Absent fields are left alone.
|
||||
//
|
||||
// MOVING A CARD IS A RELABEL. The column lives in the forge's label set, so the
|
||||
// move replaces that set rather than writing a status column here that a
|
||||
// forge-side change could contradict. Moving to `done` also CLOSES the issue on
|
||||
// the forge, because a done card and an open issue are a contradiction.
|
||||
func (o ops) forgePatchIssue(ctx context.Context, in *issueEdit) (*issueView, error) {
|
||||
cl, org, err := o.scopeForge(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Num <= 0 {
|
||||
return nil, zip.ErrBadRequest("bad issue number")
|
||||
}
|
||||
var patch forge.IssuePatch
|
||||
if in.Title != "" {
|
||||
patch.Title = &in.Title
|
||||
}
|
||||
if in.Description != "" {
|
||||
patch.Body = &in.Description
|
||||
}
|
||||
if in.Status != "" {
|
||||
if !statuses[in.Status] {
|
||||
return nil, zip.ErrBadRequest("unknown status")
|
||||
}
|
||||
state := "open"
|
||||
if in.Status == "done" || in.Status == "canceled" {
|
||||
state = "closed"
|
||||
}
|
||||
patch.State = &state
|
||||
}
|
||||
if patch.Title != nil || patch.Body != nil || patch.State != nil {
|
||||
if err := cl.PatchIssue(ctx, org, in.Key, in.Num, patch); err != nil {
|
||||
return nil, o.answer(err)
|
||||
}
|
||||
}
|
||||
// The relabel is a SEPARATE call because the forge models the label set as its
|
||||
// own sub-resource, and replacing it is the one unambiguous "move".
|
||||
if in.Status != "" || in.Priority != "" {
|
||||
labels, err := columnLabels(in.Status, in.Priority)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cl.SetLabels(ctx, org, in.Key, in.Num, labels); err != nil {
|
||||
return nil, o.answer(err)
|
||||
}
|
||||
}
|
||||
// Answer with the row as the FORGE now holds it, not with the patch echoed
|
||||
// back: the forge is the source of truth, and a response assembled from the
|
||||
// request would be this surface asserting a state it has not confirmed.
|
||||
rows, err := cl.Issues(ctx, org, forge.IssueFilter{State: "all"})
|
||||
if err != nil {
|
||||
return nil, o.answer(err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
v := forgeIssue(r)
|
||||
if strings.EqualFold(v.Repo, in.Key) && int64(v.Number) == in.Num {
|
||||
return &v, nil
|
||||
}
|
||||
}
|
||||
return nil, zip.ErrNotFound("no such issue")
|
||||
}
|
||||
|
||||
// columnLabels renders a board column and a priority as the forge label set that
|
||||
// represents them. Validated against the SAME closed sets the board renders from
|
||||
// (statuses, priorities), so a column can never be written that cannot be read
|
||||
// back.
|
||||
func columnLabels(status, priority string) ([]string, error) {
|
||||
out := []string{}
|
||||
if status != "" {
|
||||
if !statuses[status] {
|
||||
return nil, zip.ErrBadRequest("unknown status")
|
||||
}
|
||||
out = append(out, status)
|
||||
}
|
||||
if priority != "" && priority != "none" {
|
||||
if !priorities[priority] {
|
||||
return nil, zip.ErrBadRequest("unknown priority")
|
||||
}
|
||||
out = append(out, priority)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// projectLifecycle refuses to create, rename or delete a board.
|
||||
//
|
||||
// A board IS a repository on the forge. Its lifecycle is a forge operation with
|
||||
// forge permissions, and offering a second door onto it here would mean this
|
||||
// surface's guard, not the forge's, decided who may make and destroy
|
||||
// repositories — a weaker guard on the same object.
|
||||
//
|
||||
// 405 and not 404: the route exists and the answer is "not this service's job",
|
||||
// which is a different fact from "no such thing", and the message names where
|
||||
// the job IS done.
|
||||
func projectLifecycle(c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusMethodNotAllowed, map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": "forge_owns_repositories",
|
||||
"message": "a board is a repository on the forge — create, rename and delete it there; " +
|
||||
"this surface reads and moves the work items on it",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
package tracker
|
||||
|
||||
// source_test.go is the tracker's harness now that THE FORGE IS THE STORE.
|
||||
//
|
||||
// The stub below enforces the real forge's two load-bearing behaviours, both
|
||||
// measured against git.hanzo.ai before being written down (forge/forge.go states
|
||||
// the measurement):
|
||||
//
|
||||
// - Sudo DROPS PRIVILEGE. A sudoed request sees exactly what that user sees,
|
||||
// which is what makes one machine credential safe to hold.
|
||||
// - An unknown sudo user is 404, not an empty list.
|
||||
//
|
||||
// A harness that answered every request identically would let a cross-tenant
|
||||
// read pass, so the stub models visibility per actor and the tenancy tests are
|
||||
// written against it.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/authz"
|
||||
"github.com/hanzoai/cloud"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// kmsStub answers the one secret the tracker reads. It is NOT a general KMS: a
|
||||
// ref it does not know is an error, so a test that mis-names the ref fails
|
||||
// rather than silently authenticating with an empty token.
|
||||
type kmsStub struct{ token string }
|
||||
|
||||
func (k kmsStub) GetSecret(_ context.Context, ref string) ([]byte, error) {
|
||||
if ref != tokenRef {
|
||||
return nil, errUnknownRef
|
||||
}
|
||||
return []byte(k.token), nil
|
||||
}
|
||||
func (kmsStub) PutSecret(context.Context, string, []byte) error { return nil }
|
||||
func (kmsStub) DeleteSecret(context.Context, string) error { return nil }
|
||||
func (kmsStub) Sign(context.Context, string, []byte) ([]byte, error) { return nil, nil }
|
||||
|
||||
var errUnknownRef = &refError{}
|
||||
|
||||
type refError struct{}
|
||||
|
||||
func (*refError) Error() string { return "unknown secret ref" }
|
||||
|
||||
// stubForge is a fake forge with per-actor visibility.
|
||||
type stubForge struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
// visible maps a forge actor to the orgs that actor may see.
|
||||
visible map[string][]string
|
||||
// repos and issues are keyed by org; milestones by "org/repo".
|
||||
repos map[string][]map[string]any
|
||||
issues map[string][]map[string]any
|
||||
milestones map[string][]map[string]any
|
||||
// writes records every mutating request, so attribution can be asserted.
|
||||
writes []write
|
||||
token string
|
||||
}
|
||||
|
||||
type write struct {
|
||||
method, path, actor string
|
||||
body map[string]any
|
||||
}
|
||||
|
||||
func newForge(t *testing.T) *stubForge {
|
||||
t.Helper()
|
||||
f := &stubForge{
|
||||
visible: map[string][]string{},
|
||||
repos: map[string][]map[string]any{},
|
||||
issues: map[string][]map[string]any{},
|
||||
milestones: map[string][]map[string]any{},
|
||||
token: "forge-machine-token",
|
||||
}
|
||||
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if r.Header.Get("Authorization") != "token "+f.token {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
actor := r.Header.Get("Sudo")
|
||||
orgs, known := f.visible[actor]
|
||||
if !known {
|
||||
w.WriteHeader(http.StatusNotFound) // the forge's answer for an unknown sudo user
|
||||
return
|
||||
}
|
||||
sees := func(org string) bool {
|
||||
for _, o := range orgs {
|
||||
if o == org {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/v1")
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
// A write must be visible to the actor's org or the forge refuses it.
|
||||
seg := strings.Split(strings.TrimPrefix(path, "/repos/"), "/")
|
||||
if len(seg) > 0 && !sees(seg[0]) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
f.writes = append(f.writes, write{r.Method, path, actor, body})
|
||||
writeJSON(w, map[string]any{"id": 99, "number": 7, "title": body["title"], "state": "open"})
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case path == "/repos/issues/search":
|
||||
org := r.URL.Query().Get("owner")
|
||||
if !sees(org) {
|
||||
writeJSON(w, []any{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, f.issues[org])
|
||||
case strings.HasPrefix(path, "/orgs/") && strings.HasSuffix(path, "/repos"):
|
||||
org := strings.TrimSuffix(strings.TrimPrefix(path, "/orgs/"), "/repos")
|
||||
if !sees(org) {
|
||||
writeJSON(w, []any{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, f.repos[org])
|
||||
case strings.HasSuffix(path, "/milestones"):
|
||||
seg := strings.Split(strings.TrimPrefix(path, "/repos/"), "/")
|
||||
if len(seg) < 2 || !sees(seg[0]) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, f.milestones[seg[0]+"/"+seg[1]])
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(f.Server.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// repo adds a repository the given actors can see, with its issues.
|
||||
func (f *stubForge) repo(org, name string, issues ...map[string]any) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.repos[org] = append(f.repos[org], map[string]any{"name": name, "full_name": org + "/" + name})
|
||||
for _, is := range issues {
|
||||
is["repository"] = map[string]any{"name": name, "full_name": org + "/" + name, "owner": org}
|
||||
f.issues[org] = append(f.issues[org], is)
|
||||
}
|
||||
}
|
||||
|
||||
// issue builds a forge issue row.
|
||||
func issue(number int, title, state string, labels ...string) map[string]any {
|
||||
ls := []map[string]any{}
|
||||
for _, l := range labels {
|
||||
ls = append(ls, map[string]any{"name": l})
|
||||
}
|
||||
return map[string]any{"id": number, "number": number, "title": title, "state": state, "labels": ls}
|
||||
}
|
||||
|
||||
// mountForge mounts the tracker against a stub forge and a stub KMS.
|
||||
func mountForge(t *testing.T, f *stubForge) *zip.App {
|
||||
t.Helper()
|
||||
t.Setenv("CLOUD_FORGE_HOST", f.URL)
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
compose(app)
|
||||
err := Mount(app, cloud.Deps{
|
||||
Logger: luxlog.New("test"), DataDir: t.TempDir(),
|
||||
KMS: kmsStub{token: f.token},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = Shutdown() })
|
||||
return app
|
||||
}
|
||||
|
||||
// asUser issues a request as a VALIDATED principal: org + user id + the IAM
|
||||
// username the forge is sudoed as. All three are minted by the identity boundary
|
||||
// from validated claims in production; a test supplies them directly because it
|
||||
// IS the boundary here.
|
||||
func asUser(t *testing.T, app *zip.App, method, path, org, user string, body any) (int, []byte) {
|
||||
t.Helper()
|
||||
return doWireAs(t, app, method, path, org, user, body)
|
||||
}
|
||||
|
||||
// ── the tenancy gate ─────────────────────────────────────────────────────────
|
||||
|
||||
// The org is derived from the validated principal and the forge is asked through
|
||||
// the caller's OWN eyes. Both controls are asserted here, because either alone
|
||||
// would let one of these cases through.
|
||||
func TestForgeTenancy_OrgComesFromThePrincipalAndTheForgeReChecksIt(t *testing.T) {
|
||||
f := newForge(t)
|
||||
f.visible["alice"] = []string{"acme"}
|
||||
f.visible["mallory"] = []string{"umbrella"}
|
||||
f.repo("acme", "api", issue(1, "acme private work", "open", "todo"))
|
||||
f.repo("umbrella", "evil", issue(9, "umbrella secret", "open"))
|
||||
app := mountForge(t, f)
|
||||
|
||||
t.Run("a member reads their own org", func(t *testing.T) {
|
||||
code, raw := asUser(t, app, http.MethodGet, "/v1/tracker/projects/api/issues", "acme", "alice", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET = %d %s", code, raw)
|
||||
}
|
||||
var rows []map[string]any
|
||||
_ = json.Unmarshal(raw, &rows)
|
||||
if len(rows) != 1 || rows[0]["title"] != "acme private work" {
|
||||
t.Fatalf("alice got %s, want acme's one issue", raw)
|
||||
}
|
||||
})
|
||||
|
||||
// THE CROSS-TENANT CASE. Mallory's validated org is umbrella, so the surface
|
||||
// asks the forge for umbrella — never for the org she might name. She cannot
|
||||
// reach acme's board at all.
|
||||
t.Run("a member of another org reads nothing of acme's", func(t *testing.T) {
|
||||
code, raw := asUser(t, app, http.MethodGet, "/v1/tracker/projects/api/issues", "umbrella", "mallory", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET = %d %s", code, raw)
|
||||
}
|
||||
if strings.Contains(string(raw), "acme private work") {
|
||||
t.Fatalf("CROSS-TENANT READ: umbrella saw acme's issue: %s", raw)
|
||||
}
|
||||
})
|
||||
|
||||
// Naming another org in a header does not move the scope: X-Org-Id is an
|
||||
// authority header, stripped on ingress and re-minted only from claims.
|
||||
t.Run("naming acme while validated as umbrella reads nothing of acme's", func(t *testing.T) {
|
||||
code, raw := asUser(t, app, http.MethodGet, "/v1/tracker/projects/api/issues", "acme", "mallory", nil)
|
||||
if code == http.StatusOK && strings.Contains(string(raw), "acme private work") {
|
||||
t.Fatalf("CROSS-TENANT READ via a named org: %s", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// No validated principal ⇒ every route refuses, and none leaks a row.
|
||||
func TestForgeTenancy_NoPrincipalRefusesEverything(t *testing.T) {
|
||||
f := newForge(t)
|
||||
f.visible["alice"] = []string{"acme"}
|
||||
f.repo("acme", "api", issue(1, "secret", "open"))
|
||||
app := mountForge(t, f)
|
||||
|
||||
for _, tc := range []struct{ method, path string }{
|
||||
{http.MethodGet, "/v1/tracker/projects"},
|
||||
{http.MethodGet, "/v1/tracker/projects/api"},
|
||||
{http.MethodGet, "/v1/tracker/projects/api/issues"},
|
||||
{http.MethodGet, "/v1/tracker/milestones"},
|
||||
} {
|
||||
code, raw := asUser(t, app, tc.method, tc.path, "", "", nil)
|
||||
if code != http.StatusForbidden {
|
||||
t.Errorf("%s %s with no principal = %d, want 403", tc.method, tc.path, code)
|
||||
}
|
||||
if strings.Contains(string(raw), "secret") {
|
||||
t.Errorf("%s %s leaked a row while refusing: %s", tc.method, tc.path, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A validated org with NO IAM username has no forge identity to act as. It must
|
||||
// refuse rather than fall back to the machine credential, which would read every
|
||||
// repo the token can see.
|
||||
func TestForgeTenancy_NoActorRefusesRatherThanUsingTheMachineIdentity(t *testing.T) {
|
||||
f := newForge(t)
|
||||
f.visible[""] = []string{"acme"} // if the surface sudoed as nobody, this would answer
|
||||
f.repo("acme", "api", issue(1, "secret", "open"))
|
||||
app := mountForge(t, f)
|
||||
|
||||
// An org but no user id and no username ⇒ principal.Org already fails closed.
|
||||
code, raw := asUser(t, app, http.MethodGet, "/v1/tracker/projects", "acme", "", nil)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("no actor = %d %s, want 403", code, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the org rollup ───────────────────────────────────────────────────────────
|
||||
|
||||
// Milestones are repo-scoped on the forge; the org view is a server-side
|
||||
// fan-out, and each row must name the repo it came from.
|
||||
func TestForgeMilestones_OrgRollupFansOutServerSide(t *testing.T) {
|
||||
f := newForge(t)
|
||||
f.visible["alice"] = []string{"acme"}
|
||||
f.repo("acme", "api")
|
||||
f.repo("acme", "web")
|
||||
f.milestones["acme/api"] = []map[string]any{{"id": 1, "title": "v1", "state": "open", "open_issues": 3}}
|
||||
f.milestones["acme/web"] = []map[string]any{{"id": 2, "title": "launch", "state": "open", "open_issues": 5}}
|
||||
app := mountForge(t, f)
|
||||
|
||||
code, raw := asUser(t, app, http.MethodGet, "/v1/tracker/milestones", "acme", "alice", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET milestones = %d %s", code, raw)
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal(raw, &rows); err != nil {
|
||||
t.Fatalf("not an array: %s", raw)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("got %d milestones, want 2 across the org: %s", len(rows), raw)
|
||||
}
|
||||
for _, m := range rows {
|
||||
if m["repo"] == "" || m["repo"] == nil {
|
||||
t.Fatalf("milestone %v does not name its repo", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty rollup is [] and never null.
|
||||
func TestForgeMilestones_EmptyIsAnArray(t *testing.T) {
|
||||
f := newForge(t)
|
||||
f.visible["alice"] = []string{"acme"}
|
||||
app := mountForge(t, f)
|
||||
code, raw := asUser(t, app, http.MethodGet, "/v1/tracker/milestones", "acme", "alice", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("= %d %s", code, raw)
|
||||
}
|
||||
if strings.TrimSpace(string(raw)) != "[]" {
|
||||
t.Fatalf("empty rollup = %s, want []", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the board projection ─────────────────────────────────────────────────────
|
||||
|
||||
// The column is a LABEL on the forge, and a closed issue is done whatever its
|
||||
// labels say — the forge's own state is the stronger fact.
|
||||
func TestForgeBoard_ColumnComesFromTheLabelAndClosedWins(t *testing.T) {
|
||||
f := newForge(t)
|
||||
f.visible["alice"] = []string{"acme"}
|
||||
f.repo("acme", "api",
|
||||
issue(1, "labelled", "open", "in_progress", "high"),
|
||||
issue(2, "unlabelled", "open"),
|
||||
issue(3, "closed but labelled todo", "closed", "todo"),
|
||||
)
|
||||
app := mountForge(t, f)
|
||||
|
||||
code, raw := asUser(t, app, http.MethodGet, "/v1/tracker/projects/api/issues", "acme", "alice", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("= %d %s", code, raw)
|
||||
}
|
||||
var rows []map[string]any
|
||||
_ = json.Unmarshal(raw, &rows)
|
||||
by := map[string]map[string]any{}
|
||||
for _, r := range rows {
|
||||
by[r["title"].(string)] = r
|
||||
}
|
||||
if got := by["labelled"]["status"]; got != "in_progress" {
|
||||
t.Errorf("status = %v, want in_progress from the label", got)
|
||||
}
|
||||
if got := by["labelled"]["priority"]; got != "high" {
|
||||
t.Errorf("priority = %v, want high from the label", got)
|
||||
}
|
||||
// The status/priority labels are LIFTED OUT, not rendered twice.
|
||||
if ls, _ := by["labelled"]["labels"].([]any); len(ls) != 0 {
|
||||
t.Errorf("labels = %v, want the column and priority lifted out", ls)
|
||||
}
|
||||
if got := by["unlabelled"]["status"]; got != "backlog" {
|
||||
t.Errorf("unlabelled status = %v, want backlog", got)
|
||||
}
|
||||
if got := by["closed but labelled todo"]["status"]; got != "done" {
|
||||
t.Errorf("closed issue status = %v, want done — the forge's state is the stronger fact", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the writes ───────────────────────────────────────────────────────────────
|
||||
|
||||
// A card moved on the board must be relabelled ON THE FORGE, as the human.
|
||||
func TestForgeWrites_MoveIsARelabelAttributedToTheUser(t *testing.T) {
|
||||
f := newForge(t)
|
||||
f.visible["alice"] = []string{"acme"}
|
||||
f.repo("acme", "api", issue(7, "card", "open", "todo"))
|
||||
app := mountForge(t, f)
|
||||
|
||||
code, raw := asUser(t, app, http.MethodPatch, "/v1/tracker/projects/api/issues/7", "acme", "alice",
|
||||
map[string]any{"status": "in_progress"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("move = %d %s", code, raw)
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var relabel *write
|
||||
for i := range f.writes {
|
||||
if strings.HasSuffix(f.writes[i].path, "/issues/7/labels") {
|
||||
relabel = &f.writes[i]
|
||||
}
|
||||
}
|
||||
if relabel == nil {
|
||||
t.Fatalf("no relabel reached the forge; writes = %+v", f.writes)
|
||||
}
|
||||
if relabel.actor != "alice" {
|
||||
t.Fatalf("the move was attributed to %q, want alice — a shared bot identity destroys the audit trail", relabel.actor)
|
||||
}
|
||||
}
|
||||
|
||||
// The repository lifecycle is the forge's, not this surface's.
|
||||
func TestForgeWrites_RepositoryLifecycleIsRefused(t *testing.T) {
|
||||
f := newForge(t)
|
||||
f.visible["alice"] = []string{"acme"}
|
||||
app := mountForge(t, f)
|
||||
|
||||
for _, tc := range []struct{ method, path string }{
|
||||
{http.MethodPost, "/v1/tracker/projects"},
|
||||
{http.MethodPatch, "/v1/tracker/projects/api"},
|
||||
{http.MethodDelete, "/v1/tracker/projects/api"},
|
||||
} {
|
||||
code, raw := asUser(t, app, tc.method, tc.path, "acme", "alice", map[string]any{"name": "x"})
|
||||
if code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("%s %s = %d, want 405", tc.method, tc.path, code)
|
||||
}
|
||||
if !strings.Contains(string(raw), "forge") {
|
||||
t.Errorf("%s %s refusal does not name the forge: %s", tc.method, tc.path, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// doWireAs is doWire plus the IAM USERNAME the forge is sudoed as. It is a
|
||||
// separate helper rather than a parameter on doWire because the username is the
|
||||
// fact the forge-backed surface added: a validated org alone no longer scopes a
|
||||
// request, and a test that supplies only an org must keep meaning "no actor".
|
||||
func doWireAs(t *testing.T, app *zip.App, method, path, org, user string, body any) (int, []byte) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
rq := httptest.NewRequest(method, path, r)
|
||||
if body != nil {
|
||||
rq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if org != "" {
|
||||
rq.Header.Set("X-Org-Id", org)
|
||||
}
|
||||
if user != "" {
|
||||
// Both are minted by the identity boundary from validated claims; a test is
|
||||
// the boundary here. X-User-Id satisfies principal.Org's validated-principal
|
||||
// gate, X-User-Name is what the forge acts as.
|
||||
rq.Header.Set("X-User-Id", "u_"+user)
|
||||
rq.Header.Set(authz.HeaderUserName, user)
|
||||
}
|
||||
resp, err := app.Test(rq, zip.TestConfig{Timeout: wireTimeout, FailOnTimeout: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Test %s %s: %v", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, raw
|
||||
}
|
||||
|
||||
// ── the CSRF gate ────────────────────────────────────────────────────────────
|
||||
|
||||
// A browser authenticates this surface from an httpOnly session COOKIE, which is
|
||||
// AMBIENT: a page on any other origin that can reach us carries it too. The
|
||||
// deployment reflects *.hanzo.ai with credentials, and that wildcard covers hosts
|
||||
// serving arbitrary user content — so without this gate a page there could move
|
||||
// another org's cards with the visitor's own session.
|
||||
//
|
||||
// Moving to the forge did not retire this threat. It SHARPENED it: a forged write
|
||||
// now reaches the forge itself under the victim's Sudo identity, so the forge
|
||||
// would record the victim as having made the change. The gate is asserted on the
|
||||
// forge-backed writes for exactly that reason.
|
||||
func TestAmbientCookieWritesNeedCSRF(t *testing.T) {
|
||||
f := newForge(t)
|
||||
f.visible["alice"] = []string{"acme"}
|
||||
f.repo("acme", "api", issue(7, "card", "open", "todo"))
|
||||
app := mountForge(t, f)
|
||||
|
||||
// browser issues a request the way a signed-in tab does: a session COOKIE and
|
||||
// no Authorization header.
|
||||
browser := func(t *testing.T, method, path, csrf string, body any) int {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
rq := httptest.NewRequest(method, path, r)
|
||||
if body != nil {
|
||||
rq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
rq.Header.Set("Cookie", "hanzo_iam_token=session-value")
|
||||
rq.Header.Set("X-Org-Id", "acme")
|
||||
rq.Header.Set("X-User-Id", "u_alice")
|
||||
rq.Header.Set(authz.HeaderUserName, "alice")
|
||||
if csrf != "" {
|
||||
rq.Header.Set("X-CSRF-Token", csrf)
|
||||
}
|
||||
resp, err := app.Test(rq, zip.TestConfig{Timeout: wireTimeout, FailOnTimeout: true})
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
t.Run("every write is refused without a token", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
body any
|
||||
}{
|
||||
{http.MethodPost, "/v1/tracker/projects/api/issues", map[string]any{"title": "x"}},
|
||||
{http.MethodPatch, "/v1/tracker/projects/api/issues/7", map[string]any{"status": "done"}},
|
||||
} {
|
||||
if got := browser(t, tc.method, tc.path, "", tc.body); got != http.StatusForbidden {
|
||||
t.Errorf("%s %s with a session cookie and no CSRF token = %d, want 403",
|
||||
tc.method, tc.path, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a forged token is refused", func(t *testing.T) {
|
||||
if got := browser(t, http.MethodPatch, "/v1/tracker/projects/api/issues/7",
|
||||
"not-a-real-token", map[string]any{"status": "done"}); got != http.StatusForbidden {
|
||||
t.Errorf("write with a forged CSRF token = %d, want 403", got)
|
||||
}
|
||||
})
|
||||
|
||||
// THE POINT OF THE GATE: it runs BEFORE the handler, so a refused write must
|
||||
// never have reached the forge. Otherwise it is an audit trail, not a gate —
|
||||
// and the row it wrote would carry the victim's name.
|
||||
t.Run("the refusal never reached the forge", func(t *testing.T) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.writes) != 0 {
|
||||
t.Fatalf("%d CSRF-refused writes still reached the forge: %+v", len(f.writes), f.writes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reads are not gated", func(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/v1/tracker/projects",
|
||||
"/v1/tracker/projects/api",
|
||||
"/v1/tracker/projects/api/issues",
|
||||
"/v1/tracker/milestones",
|
||||
} {
|
||||
if got := browser(t, http.MethodGet, path, "", nil); got != http.StatusOK {
|
||||
t.Errorf("GET %s from a signed-in tab = %d, want 200 — reads change nothing", path, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a header-authenticated caller is unaffected", func(t *testing.T) {
|
||||
// Not CSRF-able: a cross-site page cannot set Authorization. Gating it would
|
||||
// break every API client and the gateway-fronted path for no gain.
|
||||
if code, raw := asUser(t, app, http.MethodPatch, "/v1/tracker/projects/api/issues/7", "acme", "alice",
|
||||
map[string]any{"status": "in_progress"}); code != http.StatusOK {
|
||||
t.Errorf("header-auth write = %d, want 200 (%s)", code, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── the surviving store ──────────────────────────────────────────────────────
|
||||
|
||||
// The per-(org, IAM project) SQLite store is no longer behind /v1/tracker — the
|
||||
// forge is. It still backs the two PLANE doors (upsert_plane.go and the agent-PR
|
||||
// seam), so its physical tenant boundary is still load-bearing and still pinned
|
||||
// here: two IAM projects under ONE org are two files, and neither can read the
|
||||
// other's rows.
|
||||
//
|
||||
// Driven through storeFor rather than over HTTP, because HTTP no longer reaches
|
||||
// it. Testing it through a door it no longer has would prove nothing.
|
||||
func TestPerProjectStoreFileIsolation(t *testing.T) {
|
||||
f := newForge(t)
|
||||
app := mountForge(t, f)
|
||||
_ = app
|
||||
|
||||
alpha, err := storeFor(mounted, "acme", "alpha")
|
||||
if err != nil {
|
||||
t.Fatalf("open alpha: %v", err)
|
||||
}
|
||||
beta, err := storeFor(mounted, "acme", "beta")
|
||||
if err != nil {
|
||||
t.Fatalf("open beta: %v", err)
|
||||
}
|
||||
if alpha == beta {
|
||||
t.Fatal("two IAM projects resolved to ONE store: the physical project boundary is gone")
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
if err := alpha.CreateProject(ctx, Project{
|
||||
ID: "p_alpha", Org: "acme", Key: "ENG", Name: "Engineering",
|
||||
}); err != nil {
|
||||
t.Fatalf("create under alpha: %v", err)
|
||||
}
|
||||
|
||||
// Under IAM project beta the SAME org sees nothing — physical isolation.
|
||||
rows, err := beta.ListProjects(ctx, "acme")
|
||||
if err != nil {
|
||||
t.Fatalf("list under beta: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("beta saw %d of alpha's projects: %+v", len(rows), rows)
|
||||
}
|
||||
|
||||
// And a DIFFERENT org sees nothing of acme's, in the same file.
|
||||
rows, err = alpha.ListProjects(ctx, "other")
|
||||
if err != nil {
|
||||
t.Fatalf("list as other: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("another org read %d of acme's projects: %+v", len(rows), rows)
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// doScoped runs a JSON request carrying a validated principal (X-Org-Id +
|
||||
// X-User-Id) and an optional X-Project-Id sub-scope — the header tracker keys
|
||||
// its per-project SQLite file on.
|
||||
func doScoped(t *testing.T, app *zip.App, method, path, org, project string, body any) (int, []byte) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
req.Header.Set("X-User-Id", "u_"+org) // validated principal (org() gates on it)
|
||||
}
|
||||
if project != "" {
|
||||
req.Header.Set("X-Project-Id", project)
|
||||
}
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test %s %s: %v", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, b
|
||||
}
|
||||
|
||||
// TestPerProjectStoreFileIsolation proves tracker is PROJECT-scoped: two IAM
|
||||
// projects under ONE org resolve to two nested SQLite files, and a tracker
|
||||
// project created under one IAM project is invisible under another (no
|
||||
// cross-project read) — the physical project boundary.
|
||||
func TestPerProjectStoreFileIsolation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
compose(app)
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: dir}); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = Shutdown() })
|
||||
|
||||
// Under IAM project "alpha", org acme creates tracker project ENG.
|
||||
if code, b := doScoped(t, app, http.MethodPost, "/v1/tracker/projects", "acme", "alpha",
|
||||
map[string]any{"key": "ENG", "name": "Engineering"}); code != http.StatusCreated {
|
||||
t.Fatalf("create under alpha: %d %s", code, b)
|
||||
}
|
||||
|
||||
// Under IAM project "beta", the SAME org sees NO projects — physical isolation.
|
||||
code, b := doScoped(t, app, http.MethodGet, "/v1/tracker/projects", "acme", "beta", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("list under beta: %d %s", code, b)
|
||||
}
|
||||
var betaList []trackerProject
|
||||
if err := json.Unmarshal(b, &betaList); err != nil {
|
||||
t.Fatalf("beta list json: %v (%s)", err, b)
|
||||
}
|
||||
if len(betaList) != 0 {
|
||||
t.Fatalf("IAM project beta saw alpha's tracker project: %+v", betaList)
|
||||
}
|
||||
|
||||
// Under "alpha" the project is visible.
|
||||
code, b = doScoped(t, app, http.MethodGet, "/v1/tracker/projects", "acme", "alpha", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("list under alpha: %d %s", code, b)
|
||||
}
|
||||
var alphaList []trackerProject
|
||||
if err := json.Unmarshal(b, &alphaList); err != nil {
|
||||
t.Fatalf("alpha list json: %v (%s)", err, b)
|
||||
}
|
||||
if len(alphaList) != 1 || alphaList[0].Key != "ENG" {
|
||||
t.Fatalf("alpha should see exactly ENG, got %+v", alphaList)
|
||||
}
|
||||
|
||||
// Two nested per-project stores exist, each at its own path.
|
||||
//
|
||||
// Closed first: on the pure-Go codec the encrypted database is materialized
|
||||
// only when the handle closes, so a stat while the stores are still open finds
|
||||
// nothing. Two files at two nested paths are what this test is about — two
|
||||
// physically distinct per-project stores.
|
||||
if err := Shutdown(); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
fAlpha := filepath.Join(dir, "orgs", "acme", "projects", "alpha", "tracker.db")
|
||||
fBeta := filepath.Join(dir, "orgs", "acme", "projects", "beta", "tracker.db")
|
||||
for _, p := range []string{fAlpha, fBeta} {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatalf("expected a nested per-project tracker store at %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
-63
@@ -108,6 +108,11 @@ var sources = map[string]bool{
|
||||
// CLOUD_TRACKER_FEE_CENTS[_PROJECT|_ISSUE].
|
||||
type state struct {
|
||||
stores *cloud.OrgStore[*Store] // per-(org,project) tracker DBs, opened once each
|
||||
|
||||
// forge is the SOURCE OF TRUTH for the board (source.go). The reads below are
|
||||
// reads OF the forge; nothing here caches or mirrors its rows, because a
|
||||
// second copy of a work item is a second answer to what its state is.
|
||||
forge *forgeSource
|
||||
}
|
||||
|
||||
// mounted is the active service so Shutdown can release the stores.
|
||||
@@ -153,6 +158,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
b := cloud.NewBase(deps, "tracker")
|
||||
s := &cloud.Service[state]{Base: b, State: state{
|
||||
stores: cloud.NewOrgStore(b, "tracker", openStore),
|
||||
forge: &forgeSource{},
|
||||
}}
|
||||
mounted = s
|
||||
routes(app, s)
|
||||
@@ -180,56 +186,33 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
// routes below register, so a description whose route moved stops rendering rather
|
||||
// than drifting.
|
||||
func init() {
|
||||
openapi.Describe("/v1/tracker/projects", http.MethodPost,
|
||||
"Open a tracker board in your org",
|
||||
"Creates a board and returns it, including the KEY that will prefix every issue "+
|
||||
"identifier filed under it — the same key GET, PATCH and DELETE address the board by, and "+
|
||||
"the ENG in ENG-14.\n\n"+
|
||||
"`name` is required. `key` is optional and is UPPERCASED: omit it and one is derived from "+
|
||||
"the name — its first four letters and digits, or PRJ when that yields nothing usable. A "+
|
||||
"key that is not 2-8 characters starting with a letter is 400.\n\n"+
|
||||
"THE KEY IS UNIQUE PER ORG AND A COLLISION IS REFUSED, NOT MERGED: a second board on a key "+
|
||||
"already taken is 409, and the derived key is not made unique for you, so two similarly "+
|
||||
"named boards collide and the second caller must name a key. Re-POSTing is therefore not "+
|
||||
"idempotent — it fails rather than returning the existing board.\n\n"+
|
||||
"The org is the validated bearer's own, never a client header, and the board is stored "+
|
||||
"under the caller's selected IAM PROJECT: the same key in two IAM projects is two "+
|
||||
"unrelated boards. 403 without a validated org.\n\n"+
|
||||
"Free by default. The create runs the shared per-org balance gate at a fee of zero unless "+
|
||||
"a deployment prices it, and a priced deployment out of balance refuses with the nested "+
|
||||
"{\"error\":{\"code\",\"message\"}} body at 402/503 rather than a flat error.")
|
||||
// The three repository-lifecycle routes. They are raw handlers answering ONE
|
||||
// refusal (projectLifecycle in source.go), so there is no doc comment for
|
||||
// zipdoc to lift and the prose is written here — the same reason the creates
|
||||
// used to be described here, for a route that now does the opposite.
|
||||
//
|
||||
// Every OTHER operation on this surface is a typed op whose description zipdoc
|
||||
// lifts from its doc comment. POST .../issues USED to be described here too;
|
||||
// it is a typed op now (ops.forgeCreateIssue), so its prose comes from its
|
||||
// comment and a second copy here could only drift from it.
|
||||
for _, m := range []struct{ path, method string }{
|
||||
{"/v1/tracker/projects", http.MethodPost},
|
||||
{"/v1/tracker/projects/:key", http.MethodPatch},
|
||||
{"/v1/tracker/projects/:key", http.MethodDelete},
|
||||
} {
|
||||
openapi.Describe(m.path, m.method,
|
||||
"Refused — a board is a repository on the forge",
|
||||
"Answers 405. A tracker board IS a repository on this deployment's forge, so creating, "+
|
||||
"renaming and deleting one is a FORGE operation carried out with FORGE permissions.\n\n"+
|
||||
"Offering it here would put a second door on the same object, guarded by this surface "+
|
||||
"instead of by the forge — a weaker guard on the same thing. So the route exists and "+
|
||||
"refuses, rather than 404ing: \"not this service's job\" and \"no such thing\" are "+
|
||||
"different facts, and the body names the forge so a caller knows where the job IS done.\n\n"+
|
||||
"What this surface DOES own is the work on a board: list the boards you can see, read "+
|
||||
"and file their issues, move a card between columns, and roll milestones up across the "+
|
||||
"org. Those are the routes beside this one.")
|
||||
}
|
||||
|
||||
openapi.Describe("/v1/tracker/projects/:key/issues", http.MethodPost,
|
||||
"File an issue on a tracker board",
|
||||
"Files a work item on one board and returns it, carrying the `identifier` — KEY-<number> — "+
|
||||
"it will be known by everywhere else.\n\n"+
|
||||
"THE NUMBER IS THE SERVER'S TO ASSIGN and is not accepted from the caller: it is the "+
|
||||
"board's highest plus one, taken inside the insert's own transaction, and it counts PER "+
|
||||
"BOARD — ENG-1 and OPS-1 are different issues.\n\n"+
|
||||
"`title` is required; everything else is optional and defaults. `kind` (issue, pr, epic) "+
|
||||
"says what the item IS, `source` (team, git, crm, helpdesk, cms, agent) says which surface "+
|
||||
"OPENED it, and the two are orthogonal — an issue escalated from support is "+
|
||||
"kind=issue&source=helpdesk. `status` defaults to backlog, `priority` to none. A value "+
|
||||
"outside one of these closed sets is 400, never silently defaulted. `labels` may not "+
|
||||
"contain a comma, the storage separator.\n\n"+
|
||||
"`startAt` and `dueAt` place the item on the TIMELINE, in unix seconds, and both "+
|
||||
"default to unset. A due date on its own is a milestone — an interval of zero length "+
|
||||
"— and the two together are a bar; a start with no due date is work under way with no "+
|
||||
"deadline. There is no separate milestone resource: a milestone is this row, dated. A "+
|
||||
"negative bound, or a due date before its start, is 400 rather than a silently "+
|
||||
"reordered interval.\n\n"+
|
||||
"`repo` and `extRef` RECORD an external binding; they do not create one. Filing here "+
|
||||
"writes to your tracker and reaches no external system — nothing is pushed to GitHub. The "+
|
||||
"GitHub integration runs the other way, mirroring upstream issues INTO this tracker.\n\n"+
|
||||
"404 when the caller's org has no board under that key. The org is the validated bearer's "+
|
||||
"own and the board is resolved within the caller's selected IAM project; 403 without a "+
|
||||
"validated org. Free by default, on the same balance gate as the board create — an epic, "+
|
||||
"a pull request and an issue are priced identically, since the fee is per work item rather "+
|
||||
"than per kind.")
|
||||
|
||||
// The board's two addresses. Bound with All(), so they publish every method
|
||||
// the generator knows and none of them can lift prose from a handler — a
|
||||
// static bundle has no typed op. The ONE helper every embedded SPA uses.
|
||||
openapi.DescribeSPA("/tracker", "tracker board")
|
||||
}
|
||||
|
||||
@@ -250,21 +233,31 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
|
||||
// subsystem registers a route — an order only the whole program can assert.
|
||||
// The ops below read what it parks off the context.
|
||||
|
||||
// UNTYPED BY DESIGN — the pre-create balance gate renders its denial with
|
||||
// cloud.DenyResource, the fleet's nested {"error":{"code","message"}} at
|
||||
// 402/503, which a typed op's returned error cannot carry. See typed.go.
|
||||
g.Post("/projects", cloud.Handle(s, createProject))
|
||||
zip.Get(g, "/projects", o.listProjects)
|
||||
zip.Get(g, "/projects/:key", o.getProject)
|
||||
zip.Patch(g, "/projects/:key", o.updateProject)
|
||||
zip.Delete(g, "/projects/:key", o.deleteProject)
|
||||
// THE FORGE IS THE STORE (source.go). Every route below reads and writes
|
||||
// git.hanzo.ai; none of them touches a local table. That is not a preference —
|
||||
// two stores under one prefix would be two answers to "what is the state of
|
||||
// this work", and they would disagree the first time anyone used the forge
|
||||
// directly, which is every day.
|
||||
//
|
||||
// A BOARD IS A REPOSITORY, so the repository lifecycle is NOT on this surface:
|
||||
// creating, renaming and deleting a board are forge operations with forge
|
||||
// permissions, and re-exposing them here would be a second door onto the same
|
||||
// object with its own weaker guard. Those four routes answer 405 naming the
|
||||
// forge (projectLifecycle), rather than 404 — the distinction between "no such
|
||||
// route" and "not this service's job" is the whole point.
|
||||
zip.Get(g, "/projects", o.forgeProjects)
|
||||
zip.Get(g, "/projects/:key", o.forgeProject)
|
||||
g.Post("/projects", projectLifecycle)
|
||||
g.Patch("/projects/:key", projectLifecycle)
|
||||
g.Delete("/projects/:key", projectLifecycle)
|
||||
|
||||
// UNTYPED BY DESIGN — same balance gate, same nested denial. See typed.go.
|
||||
g.Post("/projects/:key/issues", cloud.Handle(s, createIssue))
|
||||
zip.Get(g, "/projects/:key/issues", o.listIssues)
|
||||
zip.Get(g, "/projects/:key/issues/:num", o.getIssue)
|
||||
zip.Patch(g, "/projects/:key/issues/:num", o.updateIssue)
|
||||
zip.Delete(g, "/projects/:key/issues/:num", o.deleteIssue)
|
||||
zip.Get(g, "/projects/:key/issues", o.forgeIssues)
|
||||
zip.Post(g, "/projects/:key/issues", o.forgeCreateIssue)
|
||||
zip.Patch(g, "/projects/:key/issues/:num", o.forgePatchIssue)
|
||||
|
||||
// The org rollup the forge does not offer: milestones are repo-scoped
|
||||
// upstream, so the org view is a server-side fan-out (forge.Client.Milestones).
|
||||
zip.Get(g, "/milestones", o.forgeMilestones)
|
||||
|
||||
// The UI is a static asset bundle embedded in THIS binary (ui/) — the board
|
||||
// and timeline over the surface above. Serving it here is what lets cloud
|
||||
|
||||
+18
-610
@@ -15,7 +15,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -76,618 +75,27 @@ func doWire(t *testing.T, app *zip.App, method, path, org string, body any) (int
|
||||
return resp.StatusCode, raw
|
||||
}
|
||||
|
||||
func TestTypedOpsPreserveTheTrackerWire(t *testing.T) {
|
||||
app := mountWire(t)
|
||||
const org = "org_wire"
|
||||
|
||||
code, raw := doWire(t, app, http.MethodPost, "/v1/tracker/projects", org,
|
||||
map[string]any{"key": "ENG", "name": "Engineering"})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("create project: %d %s", code, raw)
|
||||
}
|
||||
code, raw = doWire(t, app, http.MethodPost, "/v1/tracker/projects/ENG/issues", org,
|
||||
map[string]any{"title": "first", "kind": "pr", "repo": "hanzoai/cloud"})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("create issue: %d %s", code, raw)
|
||||
}
|
||||
|
||||
t.Run("the listings answer a JSON ARRAY, not an object", func(t *testing.T) {
|
||||
for _, path := range []string{"/v1/tracker/projects", "/v1/tracker/projects/ENG/issues"} {
|
||||
code, raw := doWire(t, app, http.MethodGet, path, org, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET %s: %d %s", path, code, raw)
|
||||
}
|
||||
var arr []map[string]any
|
||||
if err := json.Unmarshal(raw, &arr); err != nil {
|
||||
t.Fatalf("GET %s did not answer an array: %v (%s)", path, err, raw)
|
||||
}
|
||||
if len(arr) != 1 {
|
||||
t.Errorf("GET %s returned %d rows, want 1", path, len(arr))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an empty listing is [] and never null", func(t *testing.T) {
|
||||
code, raw := doWire(t, app, http.MethodGet, "/v1/tracker/projects", "org_empty", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET as a fresh org: %d %s", code, raw)
|
||||
}
|
||||
if strings.TrimSpace(string(raw)) != "[]" {
|
||||
t.Errorf("empty listing = %s, want []", raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the issue filters bind from the QUERY string", func(t *testing.T) {
|
||||
code, raw := doWire(t, app, http.MethodGet, "/v1/tracker/projects/ENG/issues?kind=pr&repo=hanzoai/cloud", org, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("filtered list: %d %s", code, raw)
|
||||
}
|
||||
var arr []map[string]any
|
||||
_ = json.Unmarshal(raw, &arr)
|
||||
if len(arr) != 1 {
|
||||
t.Errorf("kind=pr&repo=… returned %d rows, want 1", len(arr))
|
||||
}
|
||||
// A filter outside its closed set is refused, never silently empty.
|
||||
if code, _ := doWire(t, app, http.MethodGet, "/v1/tracker/projects/ENG/issues?kind=nope", org, nil); code != http.StatusBadRequest {
|
||||
t.Errorf("unknown kind filter = %d, want 400", code)
|
||||
}
|
||||
// And the project 404 still WINS over a bad filter — the raw handler
|
||||
// resolved the project before it validated the query, and so must the op.
|
||||
if code, _ := doWire(t, app, http.MethodGet, "/v1/tracker/projects/NOPE/issues?kind=nope", org, nil); code != http.StatusNotFound {
|
||||
t.Errorf("bad filter on a missing project = %d, want 404", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a non-numeric issue number is 400, and a missing project still wins", func(t *testing.T) {
|
||||
if code, _ := doWire(t, app, http.MethodGet, "/v1/tracker/projects/ENG/issues/abc", org, nil); code != http.StatusBadRequest {
|
||||
t.Errorf("GET issues/abc = %d, want 400", code)
|
||||
}
|
||||
if code, _ := doWire(t, app, http.MethodGet, "/v1/tracker/projects/NOPE/issues/abc", org, nil); code != http.StatusNotFound {
|
||||
t.Errorf("GET a bad number under a missing project = %d, want 404", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the detail routes address by path, case-insensitively", func(t *testing.T) {
|
||||
code, raw := doWire(t, app, http.MethodGet, "/v1/tracker/projects/eng", org, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET projects/eng: %d %s", code, raw)
|
||||
}
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
if p["key"] != "ENG" {
|
||||
t.Errorf("key = %v, want ENG", p["key"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a PATCH omitting a field leaves it alone", func(t *testing.T) {
|
||||
code, raw := doWire(t, app, http.MethodPatch, "/v1/tracker/projects/ENG", org,
|
||||
map[string]any{"description": "the board"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("patch: %d %s", code, raw)
|
||||
}
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
if p["name"] != "Engineering" {
|
||||
t.Errorf("name = %v after a description-only patch, want it untouched", p["name"])
|
||||
}
|
||||
if p["description"] != "the board" {
|
||||
t.Errorf("description = %v, want %q", p["description"], "the board")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a PATCH body cannot smuggle a different target", func(t *testing.T) {
|
||||
// The URL is the addressing authority: zip binds the path LAST, so a body
|
||||
// field named for a path parameter never wins. Without that, a caller could
|
||||
// name one project in the URL and another in the body.
|
||||
code, raw := doWire(t, app, http.MethodPatch, "/v1/tracker/projects/ENG", org,
|
||||
map[string]any{"key": "OTHER", "name": "Renamed"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("patch: %d %s", code, raw)
|
||||
}
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
if p["key"] != "ENG" {
|
||||
t.Errorf("key = %v — the body overrode the URL", p["key"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a PATCH still REQUIRES a JSON body", func(t *testing.T) {
|
||||
// zip's typed decode skips an empty body and leaves the In at its zero
|
||||
// value, so without requireBody every bodyless PATCH would have turned
|
||||
// from 400 into 200-with-nothing-changed. Measured against the untyped
|
||||
// handler: all three of these answered 400 before the conversion.
|
||||
for _, tc := range []struct{ name, ctype, body string }{
|
||||
{"no body, no content-type", "", ""},
|
||||
{"no body, json content-type", "application/json", ""},
|
||||
{"json body, text content-type", "text/plain", `{"name":"X"}`},
|
||||
} {
|
||||
rq := httptest.NewRequest(http.MethodPatch, "/v1/tracker/projects/ENG", strings.NewReader(tc.body))
|
||||
if tc.ctype != "" {
|
||||
rq.Header.Set("Content-Type", tc.ctype)
|
||||
}
|
||||
rq.Header.Set("X-Org-Id", org)
|
||||
rq.Header.Set("X-User-Id", "u_"+org)
|
||||
resp, err := app.Test(rq, zip.TestConfig{Timeout: wireTimeout, FailOnTimeout: true})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("PATCH with %s = %d, want 400", tc.name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the ONE declared residual: an unparseable body now outranks the 404", func(t *testing.T) {
|
||||
// zip refuses a body it cannot parse BEFORE the handler runs, so a request
|
||||
// that is both unparseable AND aimed at a missing project answers 400 where
|
||||
// the raw handler answered 404 (it bound the body after the lookup). It is
|
||||
// the only measured wire difference in this conversion; it is here so it is
|
||||
// a recorded fact rather than a surprise, and so a future zip that can defer
|
||||
// the decode turns this red instead of passing silently.
|
||||
rq := httptest.NewRequest(http.MethodPatch, "/v1/tracker/projects/NOPE", strings.NewReader(`{bad`))
|
||||
rq.Header.Set("Content-Type", "application/json")
|
||||
rq.Header.Set("X-Org-Id", org)
|
||||
rq.Header.Set("X-User-Id", "u_"+org)
|
||||
resp, err := app.Test(rq, zip.TestConfig{Timeout: wireTimeout, FailOnTimeout: true})
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("unparseable body on a missing project = %d, want the declared 400", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the deletes answer 204 with NO body", func(t *testing.T) {
|
||||
code, raw := doWire(t, app, http.MethodDelete, "/v1/tracker/projects/ENG/issues/1", org, nil)
|
||||
if code != http.StatusNoContent {
|
||||
t.Fatalf("delete issue = %d, want 204", code)
|
||||
}
|
||||
if len(raw) != 0 {
|
||||
t.Errorf("delete issue body = %q, want empty", raw)
|
||||
}
|
||||
code, raw = doWire(t, app, http.MethodDelete, "/v1/tracker/projects/ENG", org, nil)
|
||||
if code != http.StatusNoContent {
|
||||
t.Fatalf("delete project = %d, want 204", code)
|
||||
}
|
||||
if len(raw) != 0 {
|
||||
t.Errorf("delete project body = %q, want empty", raw)
|
||||
}
|
||||
if code, _ := doWire(t, app, http.MethodDelete, "/v1/tracker/projects/ENG", org, nil); code != http.StatusNotFound {
|
||||
t.Errorf("second delete = %d, want 404", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestScheduleCrossesTheWire pins the timeline half of the surface end to end:
|
||||
// the create accepts an interval, the view carries it back, the bool filter
|
||||
// binds from the query string, the PATCH reschedules and clears, and an interval
|
||||
// that cannot exist is refused at the boundary — including when only ONE of its
|
||||
// bounds is in the request.
|
||||
func TestScheduleCrossesTheWire(t *testing.T) {
|
||||
app := mountWire(t)
|
||||
const org = "org_sched"
|
||||
const day = 86400
|
||||
const base = 1_700_000_000
|
||||
|
||||
if code, raw := doWire(t, app, http.MethodPost, "/v1/tracker/projects", org,
|
||||
map[string]any{"key": "ENG", "name": "Engineering"}); code != http.StatusCreated {
|
||||
t.Fatalf("create project: %d %s", code, raw)
|
||||
}
|
||||
|
||||
t.Run("a create carries an interval and the view answers with it", func(t *testing.T) {
|
||||
code, raw := doWire(t, app, http.MethodPost, "/v1/tracker/projects/ENG/issues", org,
|
||||
map[string]any{"title": "migrate store", "startAt": base, "dueAt": base + 7*day})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("create scheduled issue: %d %s", code, raw)
|
||||
}
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
if v["startAt"] != float64(base) || v["dueAt"] != float64(base+7*day) {
|
||||
t.Errorf("created schedule = (%v,%v), want (%d,%d)", v["startAt"], v["dueAt"], base, base+7*day)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a due date alone is a milestone, and an undated issue omits both", func(t *testing.T) {
|
||||
code, raw := doWire(t, app, http.MethodPost, "/v1/tracker/projects/ENG/issues", org,
|
||||
map[string]any{"title": "GA", "kind": "epic", "dueAt": base + 30*day})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("create milestone: %d %s", code, raw)
|
||||
}
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if _, ok := m["startAt"]; ok {
|
||||
t.Errorf("milestone carried a startAt: %s", raw)
|
||||
}
|
||||
if m["dueAt"] != float64(base+30*day) {
|
||||
t.Errorf("milestone dueAt = %v", m["dueAt"])
|
||||
}
|
||||
|
||||
code, raw = doWire(t, app, http.MethodPost, "/v1/tracker/projects/ENG/issues", org,
|
||||
map[string]any{"title": "triage inbox"})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("create undated: %d %s", code, raw)
|
||||
}
|
||||
var u map[string]any
|
||||
_ = json.Unmarshal(raw, &u)
|
||||
if _, ok := u["startAt"]; ok {
|
||||
t.Errorf("undated issue carried a startAt: %s", raw)
|
||||
}
|
||||
if _, ok := u["dueAt"]; ok {
|
||||
t.Errorf("undated issue carried a dueAt: %s", raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scheduled=true binds from the query string and composes", func(t *testing.T) {
|
||||
code, raw := doWire(t, app, http.MethodGet, "/v1/tracker/projects/ENG/issues?scheduled=true", org, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("timeline list: %d %s", code, raw)
|
||||
}
|
||||
var arr []map[string]any
|
||||
_ = json.Unmarshal(raw, &arr)
|
||||
if len(arr) != 2 {
|
||||
t.Errorf("scheduled=true returned %d rows, want 2 (%s)", len(arr), raw)
|
||||
}
|
||||
// Absent, the filter is off — the board keeps every row.
|
||||
code, raw = doWire(t, app, http.MethodGet, "/v1/tracker/projects/ENG/issues", org, nil)
|
||||
_ = json.Unmarshal(raw, &arr)
|
||||
if code != http.StatusOK || len(arr) != 3 {
|
||||
t.Errorf("unfiltered board returned %d rows, want 3", len(arr))
|
||||
}
|
||||
// Composes with the closed-set filters rather than replacing them.
|
||||
code, raw = doWire(t, app, http.MethodGet, "/v1/tracker/projects/ENG/issues?scheduled=true&kind=epic", org, nil)
|
||||
_ = json.Unmarshal(raw, &arr)
|
||||
if code != http.StatusOK || len(arr) != 1 || arr[0]["title"] != "GA" {
|
||||
t.Errorf("scheduled epics = %s", raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unreadable scheduled= is refused, not read as false", func(t *testing.T) {
|
||||
// zip binds a bool with ParseBool and leaves the zero value on failure, so
|
||||
// `scheduled=yes` used to answer the WHOLE board — the caller believing it
|
||||
// had filtered. Every other filter here refuses its unknown values; so does
|
||||
// this one.
|
||||
for _, q := range []string{"scheduled=yes", "scheduled=no", "scheduled=1.0", "scheduled=on"} {
|
||||
if code, raw := doWire(t, app, http.MethodGet, "/v1/tracker/projects/ENG/issues?"+q, org, nil); code != http.StatusBadRequest {
|
||||
t.Errorf("?%s = %d, want 400 (%s)", q, code, raw)
|
||||
}
|
||||
}
|
||||
// The legal spellings still work, including the bare flag.
|
||||
for _, tc := range []struct {
|
||||
q string
|
||||
rows int
|
||||
}{
|
||||
{"scheduled=true", 2}, {"scheduled=1", 2}, {"scheduled=True", 2}, {"scheduled", 2},
|
||||
{"scheduled=false", 3}, {"scheduled=0", 3},
|
||||
} {
|
||||
code, raw := doWire(t, app, http.MethodGet, "/v1/tracker/projects/ENG/issues?"+tc.q, org, nil)
|
||||
var arr []map[string]any
|
||||
_ = json.Unmarshal(raw, &arr)
|
||||
if code != http.StatusOK || len(arr) != tc.rows {
|
||||
t.Errorf("?%s = %d with %d rows, want 200 with %d", tc.q, code, len(arr), tc.rows)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a PATCH reschedules, and 0 clears", func(t *testing.T) {
|
||||
code, raw := doWire(t, app, http.MethodPatch, "/v1/tracker/projects/ENG/issues/1", org,
|
||||
map[string]any{"dueAt": base + 14*day})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("reschedule: %d %s", code, raw)
|
||||
}
|
||||
var v map[string]any
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
if v["dueAt"] != float64(base+14*day) {
|
||||
t.Errorf("dueAt = %v after reschedule", v["dueAt"])
|
||||
}
|
||||
if v["startAt"] != float64(base) {
|
||||
t.Errorf("startAt = %v — a dueAt-only patch moved the start", v["startAt"])
|
||||
}
|
||||
|
||||
code, raw = doWire(t, app, http.MethodPatch, "/v1/tracker/projects/ENG/issues/1", org,
|
||||
map[string]any{"startAt": 0, "dueAt": 0})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("clear: %d %s", code, raw)
|
||||
}
|
||||
// A FRESH map: decoding into one that already holds a key merges rather
|
||||
// than replaces, which would report a cleared field as still present.
|
||||
var cleared map[string]any
|
||||
_ = json.Unmarshal(raw, &cleared)
|
||||
if _, ok := cleared["startAt"]; ok {
|
||||
t.Errorf("cleared issue still carries a startAt: %s", raw)
|
||||
}
|
||||
if _, ok := cleared["dueAt"]; ok {
|
||||
t.Errorf("cleared issue still carries a dueAt: %s", raw)
|
||||
}
|
||||
code, raw = doWire(t, app, http.MethodGet, "/v1/tracker/projects/ENG/issues?scheduled=true", org, nil)
|
||||
var arr []map[string]any
|
||||
_ = json.Unmarshal(raw, &arr)
|
||||
if code != http.StatusOK || len(arr) != 1 {
|
||||
t.Errorf("after clearing, timeline has %d rows, want 1", len(arr))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an interval that cannot exist is refused, on create and on patch", func(t *testing.T) {
|
||||
for _, body := range []map[string]any{
|
||||
{"title": "backwards", "startAt": base + day, "dueAt": base},
|
||||
{"title": "negative", "startAt": -1},
|
||||
} {
|
||||
if code, raw := doWire(t, app, http.MethodPost, "/v1/tracker/projects/ENG/issues", org, body); code != http.StatusBadRequest {
|
||||
t.Errorf("create %v = %d, want 400 (%s)", body, code, raw)
|
||||
}
|
||||
}
|
||||
// The PATCH check is against the RESULTING interval: issue 2 is the
|
||||
// milestone at base+30d with no start, so a start after it is backwards even
|
||||
// though the request never names a due date.
|
||||
if code, raw := doWire(t, app, http.MethodPatch, "/v1/tracker/projects/ENG/issues/2", org,
|
||||
map[string]any{"startAt": base + 60*day}); code != http.StatusBadRequest {
|
||||
t.Errorf("patch a start past the stored due = %d, want 400 (%s)", code, raw)
|
||||
}
|
||||
// And the row is untouched by the refusal.
|
||||
_, raw := doWire(t, app, http.MethodGet, "/v1/tracker/projects/ENG/issues/2", org, nil)
|
||||
var v map[string]any
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
if _, ok := v["startAt"]; ok {
|
||||
t.Errorf("the refused patch still wrote a startAt: %s", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAmbientCookieWritesNeedCSRF pins the anti-CSRF gate on the browser path.
|
||||
//
|
||||
// A browser authenticates this surface from an httpOnly session cookie, which is
|
||||
// AMBIENT — carried on a cross-site page's request too — and the deployment's
|
||||
// CORS policy reflects *.hanzo.ai with credentials, a wildcard that covers hosts
|
||||
// serving arbitrary user content. So a cookie-authenticated WRITE must carry the
|
||||
// same-origin CSRF token, and one that does not is refused.
|
||||
//
|
||||
// The gate is method-discriminating and installed once on the group, so this also
|
||||
// pins what must NOT change: reads pass untouched, and a header-authenticated
|
||||
// caller (API client, gateway-fronted request) is not CSRF-able and is unaffected.
|
||||
func TestAmbientCookieWritesNeedCSRF(t *testing.T) {
|
||||
app := mountWire(t)
|
||||
const org = "org_csrf"
|
||||
|
||||
// Seed over the header path, which is not CSRF-able and therefore ungated.
|
||||
if code, raw := doWire(t, app, http.MethodPost, "/v1/tracker/projects", org,
|
||||
map[string]any{"key": "ENG", "name": "Engineering"}); code != http.StatusCreated {
|
||||
t.Fatalf("seed project: %d %s", code, raw)
|
||||
}
|
||||
if code, raw := doWire(t, app, http.MethodPost, "/v1/tracker/projects/ENG/issues", org,
|
||||
map[string]any{"title": "seed"}); code != http.StatusCreated {
|
||||
t.Fatalf("seed issue: %d %s", code, raw)
|
||||
}
|
||||
|
||||
// browser issues a request the way a signed-in tab does: a session COOKIE and
|
||||
// no Authorization header. The identity headers stand in for what the
|
||||
// composer's identity check parks from that cookie in production.
|
||||
browser := func(t *testing.T, method, path, csrf string, body any) int {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
rq := httptest.NewRequest(method, path, r)
|
||||
if body != nil {
|
||||
rq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
rq.Header.Set("Cookie", "hanzo_iam_token=session-value")
|
||||
rq.Header.Set("X-Org-Id", org)
|
||||
rq.Header.Set("X-User-Id", "u_"+org)
|
||||
if csrf != "" {
|
||||
rq.Header.Set("X-CSRF-Token", csrf)
|
||||
}
|
||||
resp, err := app.Test(rq, zip.TestConfig{Timeout: wireTimeout, FailOnTimeout: true})
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
t.Run("every write is refused without a token", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
body any
|
||||
}{
|
||||
{http.MethodPost, "/v1/tracker/projects", map[string]any{"key": "OPS", "name": "Ops"}},
|
||||
{http.MethodPatch, "/v1/tracker/projects/ENG", map[string]any{"name": "Renamed"}},
|
||||
{http.MethodDelete, "/v1/tracker/projects/ENG", nil},
|
||||
{http.MethodPost, "/v1/tracker/projects/ENG/issues", map[string]any{"title": "x"}},
|
||||
{http.MethodPatch, "/v1/tracker/projects/ENG/issues/1", map[string]any{"status": "done"}},
|
||||
{http.MethodDelete, "/v1/tracker/projects/ENG/issues/1", nil},
|
||||
} {
|
||||
if got := browser(t, tc.method, tc.path, "", tc.body); got != http.StatusForbidden {
|
||||
t.Errorf("%s %s with a session cookie and no CSRF token = %d, want 403",
|
||||
tc.method, tc.path, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a forged token is refused", func(t *testing.T) {
|
||||
if got := browser(t, http.MethodPatch, "/v1/tracker/projects/ENG",
|
||||
"not-a-real-token", map[string]any{"name": "Renamed"}); got != http.StatusForbidden {
|
||||
t.Errorf("write with a forged CSRF token = %d, want 403", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the refusal changed nothing", func(t *testing.T) {
|
||||
// The gate runs BEFORE the handler, so a refused write must not have
|
||||
// touched the board — otherwise it is an audit trail, not a gate.
|
||||
code, raw := doWire(t, app, http.MethodGet, "/v1/tracker/projects/ENG", org, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("read back: %d %s", code, raw)
|
||||
}
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
if p["name"] != "Engineering" {
|
||||
t.Errorf("name = %v — a CSRF-refused PATCH still wrote", p["name"])
|
||||
}
|
||||
code, raw = doWire(t, app, http.MethodGet, "/v1/tracker/projects/ENG/issues", org, nil)
|
||||
var arr []map[string]any
|
||||
_ = json.Unmarshal(raw, &arr)
|
||||
if code != http.StatusOK || len(arr) != 1 {
|
||||
t.Errorf("issues = %d rows, want the 1 seeded (a refused create/delete landed)", len(arr))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reads are not gated", func(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/v1/tracker/projects",
|
||||
"/v1/tracker/projects/ENG",
|
||||
"/v1/tracker/projects/ENG/issues",
|
||||
"/v1/tracker/projects/ENG/issues/1",
|
||||
} {
|
||||
if got := browser(t, http.MethodGet, path, "", nil); got != http.StatusOK {
|
||||
t.Errorf("GET %s from a signed-in tab = %d, want 200 — reads change nothing", path, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a header-authenticated caller is unaffected", func(t *testing.T) {
|
||||
// Not CSRF-able: a cross-site page cannot set Authorization. Gating it
|
||||
// would break every API client and the gateway-fronted path for no gain.
|
||||
if code, raw := doWire(t, app, http.MethodPatch, "/v1/tracker/projects/ENG", org,
|
||||
map[string]any{"description": "still works"}); code != http.StatusOK {
|
||||
t.Errorf("header-auth write = %d, want 200 (%s)", code, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestScheduleHasAHorizon pins the bound that keeps a stored date from being a
|
||||
// weapon. checkSchedule accepted any non-negative int64, so dueAt=2^63-1 was a
|
||||
// legal write — and the timeline sizes its grid from the data, so that one row
|
||||
// made every member of the org who opened the view render an unbounded number of
|
||||
// ticks. The refusal is at the WRITE because that is where the row becomes
|
||||
// everyone else's problem.
|
||||
func TestScheduleHasAHorizon(t *testing.T) {
|
||||
app := mountWire(t)
|
||||
const org = "org_horizon"
|
||||
if code, raw := doWire(t, app, http.MethodPost, "/v1/tracker/projects", org,
|
||||
map[string]any{"key": "ENG", "name": "Engineering"}); code != http.StatusCreated {
|
||||
t.Fatalf("seed project: %d %s", code, raw)
|
||||
}
|
||||
if code, raw := doWire(t, app, http.MethodPost, "/v1/tracker/projects/ENG/issues", org,
|
||||
map[string]any{"title": "seed"}); code != http.StatusCreated {
|
||||
t.Fatalf("seed issue: %d %s", code, raw)
|
||||
}
|
||||
|
||||
const maxInt64 = int64(1<<63 - 1)
|
||||
// ABSOLUTE dates, not maxScheduleAt arithmetic. A case written as
|
||||
// `maxScheduleAt + 1` moves with the constant, so loosening the horizon —
|
||||
// the exact regression this test exists to catch — would keep it green.
|
||||
// These are fixed instants the tracker must refuse whatever the constant says.
|
||||
const year2300 = int64(10413792000) // 2300-01-01T00:00:00Z
|
||||
const year9999 = int64(253370764800)
|
||||
beyond := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"int64 max as a due date", map[string]any{"title": "boom", "dueAt": maxInt64}},
|
||||
{"int64 max as a start", map[string]any{"title": "boom", "startAt": maxInt64}},
|
||||
{"the year 2300", map[string]any{"title": "boom", "dueAt": year2300}},
|
||||
{"the year 9999", map[string]any{"title": "boom", "dueAt": year9999}},
|
||||
}
|
||||
for _, tc := range beyond {
|
||||
t.Run("create: "+tc.name, func(t *testing.T) {
|
||||
if code, raw := doWire(t, app, http.MethodPost, "/v1/tracker/projects/ENG/issues", org, tc.body); code != http.StatusBadRequest {
|
||||
t.Errorf("create %v = %d, want 400 (%s)", tc.body, code, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("patch is bounded too", func(t *testing.T) {
|
||||
if code, raw := doWire(t, app, http.MethodPatch, "/v1/tracker/projects/ENG/issues/1", org,
|
||||
map[string]any{"dueAt": maxInt64}); code != http.StatusBadRequest {
|
||||
t.Errorf("patch to int64 max = %d, want 400 (%s)", code, raw)
|
||||
}
|
||||
// And nothing was stored — a refused patch must leave the row unscheduled.
|
||||
_, raw := doWire(t, app, http.MethodGet, "/v1/tracker/projects/ENG/issues/1", org, nil)
|
||||
var v map[string]any
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
if _, ok := v["dueAt"]; ok {
|
||||
t.Errorf("the refused patch stored a dueAt: %s", raw)
|
||||
}
|
||||
})
|
||||
t.Run("the horizon is where it is documented to be", func(t *testing.T) {
|
||||
// Pinned against the absolute instant, so moving the constant is a
|
||||
// deliberate edit here rather than a silent widening.
|
||||
const year2200 = int64(7258118400) // 2200-01-01T00:00:00Z
|
||||
if maxScheduleAt != year2200 {
|
||||
t.Fatalf("maxScheduleAt = %d, want %d (2200-01-01T00:00:00Z)", maxScheduleAt, year2200)
|
||||
}
|
||||
})
|
||||
t.Run("the horizon itself is still a date", func(t *testing.T) {
|
||||
// The bound is inclusive: a plan that lands exactly on it is legal. A
|
||||
// bound that refused its own edge would be an off-by-one nobody notices
|
||||
// until the one caller who hits it.
|
||||
if code, raw := doWire(t, app, http.MethodPost, "/v1/tracker/projects/ENG/issues", org,
|
||||
map[string]any{"title": "the last day", "dueAt": maxScheduleAt}); code != http.StatusCreated {
|
||||
t.Errorf("dueAt at the horizon = %d, want 201 (%s)", code, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTenancyIsNeverACallerField pins the one rule a typed op can silently break:
|
||||
// the org must come from the VALIDATED principal (cloud.Bridge parks it), never
|
||||
// from an In field. An unvalidated caller — no X-User-Id, so principal.Org
|
||||
// refuses — must be refused on every op, and a caller of one org must never read
|
||||
// another's board even by naming it.
|
||||
func TestTenancyIsNeverACallerField(t *testing.T) {
|
||||
app := mountWire(t)
|
||||
|
||||
if code, _ := doWire(t, app, http.MethodPost, "/v1/tracker/projects", "acme",
|
||||
map[string]any{"key": "SEC", "name": "Secret"}); code != http.StatusCreated {
|
||||
t.Fatalf("seed acme project")
|
||||
}
|
||||
|
||||
// A different org cannot see acme's board, even addressing it by key.
|
||||
if code, _ := doWire(t, app, http.MethodGet, "/v1/tracker/projects/SEC", "other", nil); code != http.StatusNotFound {
|
||||
t.Errorf("cross-org GET = %d, want 404", code)
|
||||
}
|
||||
if code, _ := doWire(t, app, http.MethodDelete, "/v1/tracker/projects/SEC", "other", nil); code != http.StatusNotFound {
|
||||
t.Errorf("cross-org DELETE = %d, want 404", code)
|
||||
}
|
||||
|
||||
// No validated principal: every op refuses, none leaks a row.
|
||||
for _, tc := range []struct{ method, path string }{
|
||||
{http.MethodGet, "/v1/tracker/projects"},
|
||||
{http.MethodGet, "/v1/tracker/projects/SEC"},
|
||||
{http.MethodPatch, "/v1/tracker/projects/SEC"},
|
||||
{http.MethodDelete, "/v1/tracker/projects/SEC"},
|
||||
{http.MethodGet, "/v1/tracker/projects/SEC/issues"},
|
||||
{http.MethodGet, "/v1/tracker/projects/SEC/issues/1"},
|
||||
{http.MethodPatch, "/v1/tracker/projects/SEC/issues/1"},
|
||||
{http.MethodDelete, "/v1/tracker/projects/SEC/issues/1"},
|
||||
} {
|
||||
if code, _ := doWire(t, app, tc.method, tc.path, "", nil); code != http.StatusForbidden {
|
||||
t.Errorf("%s %s with no principal = %d, want 403", tc.method, tc.path, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// untypedByDesign is the CLOSED list of tracker operations that are NOT typed
|
||||
// ops, each with the wire fact that keeps it out. A typed op is a route PLUS a
|
||||
// registry entry — the one value the OpenAPI operation, the MCP tool, the CLI
|
||||
// command and the SDK method all come from — so an operation missing from that
|
||||
// registry is invisible to all four. These two are missing on purpose. Addresses
|
||||
// are written the way the DOCUMENT writes them, which is the identity every
|
||||
// projection keys on.
|
||||
var untypedByDesign = map[string]string{
|
||||
"POST /v1/tracker/projects": "runs the pre-create balance gate and renders its denial with " +
|
||||
"cloud.DenyResource — the fleet's NESTED {\"error\":{\"code\",\"message\"}} at 402/503. A typed " +
|
||||
"op can only refuse by RETURNING an error, which zip renders as its flat {status,code,error}; " +
|
||||
"writing the nested body from inside the op does not escape it either, because a nil Out makes " +
|
||||
"zip stamp cmp.Or(op.Status, 204) over the 402 it just wrote. The fee is 0 by default, but " +
|
||||
"CLOUD_TRACKER_FEE_CENTS_PROJECT prices it, and a route that changes shape under a supported " +
|
||||
"configuration has changed shape.",
|
||||
"POST /v1/tracker/projects/{key}/issues": "same pre-create balance gate, same nested denial, " +
|
||||
"priced by CLOUD_TRACKER_FEE_CENTS_ISSUE.",
|
||||
// The three repository-lifecycle routes. A board IS a repository on the forge
|
||||
// (source.go), so creating, renaming and deleting one is a FORGE operation
|
||||
// under forge permissions; re-exposing it here would put a second, weaker door
|
||||
// on the same object. They answer 405 naming the forge — which is a different
|
||||
// fact from 404, and the reason they are routes at all rather than absent.
|
||||
//
|
||||
// Untyped because a typed op publishes a request and response schema for work
|
||||
// it does not do. There is no shape to describe: the only thing these answer
|
||||
// is a refusal.
|
||||
"POST /v1/tracker/projects": repoLifecycleReason,
|
||||
"PATCH /v1/tracker/projects/{key}": repoLifecycleReason,
|
||||
"DELETE /v1/tracker/projects/{key}": repoLifecycleReason,
|
||||
}
|
||||
|
||||
const repoLifecycleReason = "a board is a repository on the deployment's forge, so its lifecycle is a " +
|
||||
"forge operation under forge permissions — offering it here would be a second door onto the same " +
|
||||
"object with this surface's guard instead of the forge's. Answers 405 naming the forge rather than " +
|
||||
"404, because 'not this service's job' and 'no such thing' are different facts. A typed op would " +
|
||||
"publish request and response schemas for work that is never done."
|
||||
|
||||
// trackerOps reads BOTH projections of the live router at their one shared
|
||||
// address form: what the document says is served, and which of those carry a
|
||||
// typed registry entry. Reading the router (not the source) is what makes this a
|
||||
|
||||
+37
-58
@@ -3,36 +3,27 @@
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func init() {
|
||||
zip.Describe("DELETE /v1/tracker/projects/:key", zip.Doc{
|
||||
Description: "Removes one tracker project of the caller's org AND every issue\nfiled under it, and answers 204 with no body. 404 when the org has no project\nunder that key.\n\nThe cascade is the point: an issue has no meaning without the board whose key\nnames it, so deleting the board deletes them together rather than leaving\norphans addressable by an identifier that no longer resolves.",
|
||||
Fields: map[string]string{
|
||||
"projectRef.key": "Key is the project's org-unique handle: 2-8 uppercase alphanumerics starting\nwith a letter (\"ENG\", \"OPS2\"). Matched case-insensitively.",
|
||||
},
|
||||
Description: "Refuses to create, rename or delete a board.\n\nA board IS a repository on the forge. Its lifecycle is a forge operation with\nforge permissions, and offering a second door onto it here would mean this\nsurface's guard, not the forge's, decided who may make and destroy\nrepositories — a weaker guard on the same object.\n\n405 and not 404: the route exists and the answer is \"not this service's job\",\nwhich is a different fact from \"no such thing\", and the message names where\nthe job IS done.",
|
||||
})
|
||||
zip.Describe("DELETE /v1/tracker/projects/:key/issues/:num", zip.Doc{
|
||||
Description: "Removes one issue from a tracker project and answers 204 with no\nbody. 404 when the project or the issue does not exist in the caller's org.\n\nThe issue's number is NOT reused: the next issue on the board takes the next\nnumber, so a deleted identifier stays retired rather than silently pointing at\ndifferent work.",
|
||||
Fields: map[string]string{
|
||||
"issueRef.key": "Key is the issue's project, from the path.",
|
||||
"issueRef.num": "Num is the issue's number within that project — the digits of KEY-14.\nPositive; anything else is refused with 400.",
|
||||
},
|
||||
zip.Describe("GET /v1/tracker/milestones", zip.Doc{
|
||||
Description: "Returns every milestone across your org's repositories, each\nstamped with the repository it belongs to.\n\nThe forge scopes milestones to a repository and publishes no org-level list,\nso this is a server-side fan-out over the repositories you can see. It runs\nhere rather than in the browser because a client-side fan-out would need the\nforge reachable from the page and a credential held there.",
|
||||
})
|
||||
zip.Describe("GET /v1/tracker/projects", zip.Doc{
|
||||
Description: "Returns every tracker project in the caller's org, newest first.\n\nA project is the board: it owns a KEY (the uppercase handle that prefixes every\nissue identifier, \"ENG-14\") and the issues filed under it. The listing is\norg-scoped server-side — the org is the validated bearer claim, never a\nclient-supplied header — so one org can never see another's boards.",
|
||||
Description: "Returns the boards of your org — one per repository on the\ndeployment's forge that you can see. The key is the repository name, and it is\nwhat addresses the board's issues.\n\nArchived repositories are omitted: they are not live work. The set is the\nFORGE's answer for your own account, so two people in one org can legitimately\nsee different boards.",
|
||||
})
|
||||
zip.Describe("GET /v1/tracker/projects/:key", zip.Doc{
|
||||
Description: "Returns one tracker project of the caller's org by its key —\nits name, description and timestamps. 404 when the org has no project\nunder that key.",
|
||||
Description: "Returns one board of your org by its key — the repository name.\n404 when your org has no repository under that key, or when your own forge\naccount cannot see it.",
|
||||
Fields: map[string]string{
|
||||
"projectRef.key": "Key is the project's org-unique handle: 2-8 uppercase alphanumerics starting\nwith a letter (\"ENG\", \"OPS2\"). Matched case-insensitively.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/tracker/projects/:key/issues", zip.Doc{
|
||||
Description: "Returns the issues of one tracker project, optionally filtered by\nstatus, kind, repo, source and whether they are scheduled.\n\nThis is the ONE place a surface takes its slice of the shared issue table: the\nboard passes no filter or a status, the timeline passes scheduled=true, a git\nrepository's Issues tab passes kind=issue&repo=<r> and its Pull Requests tab\nkind=pr&repo=<r>. A filter value outside its closed set is refused with 400\nrather than silently returning an empty board.",
|
||||
Description: "Returns one board's issues — the work items of that repository on\nthe forge, with their column, priority, assignee and labels.\n\nThe column is a LABEL on the forge, so the board and the forge web UI are the\nsame object seen twice: relabelling in either moves the card in both. A closed\nissue reads as done whatever its labels say.",
|
||||
Fields: map[string]string{
|
||||
"issueQuery.key": "Key is the project whose issues to list, from the path.",
|
||||
"issueQuery.kind": "Kind keeps only work items of that shape: issue, pr or epic. An unknown\nvalue is refused with 400.",
|
||||
@@ -48,53 +39,27 @@ func init() {
|
||||
"issueView.source": "team | git | crm | helpdesk | cms | agent",
|
||||
"issueView.startAt": "unix seconds; absent = unscheduled",
|
||||
},
|
||||
Example: json.RawMessage(`{"key":"ENG","kind":"pr","repo":"hanzoai/cloud"}`),
|
||||
})
|
||||
zip.Describe("GET /v1/tracker/projects/:key/issues/:num", zip.Doc{
|
||||
Description: "Returns one issue of one tracker project by its per-project number —\ntitle, description, status, priority, assignee, labels, kind, source and its\ngit bindings. 404 when the project or the issue does not exist in the caller's\norg.",
|
||||
Fields: map[string]string{
|
||||
"issueRef.key": "Key is the issue's project, from the path.",
|
||||
"issueRef.num": "Num is the issue's number within that project — the digits of KEY-14.\nPositive; anything else is refused with 400.",
|
||||
"issueView.dueAt": "unix seconds; absent = no due date",
|
||||
"issueView.extRef": "external anchor",
|
||||
"issueView.identifier": "KEY-<number>, the human handle",
|
||||
"issueView.kind": "issue | pr | epic",
|
||||
"issueView.repo": "git repo binding",
|
||||
"issueView.source": "team | git | crm | helpdesk | cms | agent",
|
||||
"issueView.startAt": "unix seconds; absent = unscheduled",
|
||||
},
|
||||
})
|
||||
zip.Describe("PATCH /v1/tracker/projects/:key", zip.Doc{
|
||||
Description: "Renames a tracker project or rewrites its description, and\nreturns the updated project. Both fields are optional: one the caller omits\nkeeps its stored value.\n\nThe project KEY is never editable — it prefixes every issue identifier already\nfiled under the board, so changing it would rewrite the human handle of every\nissue in it.",
|
||||
Fields: map[string]string{
|
||||
"projectPatch.description": "Description is the board's free-form blurb, at most 32768 characters.",
|
||||
"projectPatch.key": "Key is the project to update, from the path.",
|
||||
"projectPatch.name": "Name is the project's display name. Non-empty, at most 256 characters.",
|
||||
},
|
||||
Example: json.RawMessage(`{"name":"Platform Engineering"}`),
|
||||
Description: "Refuses to create, rename or delete a board.\n\nA board IS a repository on the forge. Its lifecycle is a forge operation with\nforge permissions, and offering a second door onto it here would mean this\nsurface's guard, not the forge's, decided who may make and destroy\nrepositories — a weaker guard on the same object.\n\n405 and not 404: the route exists and the answer is \"not this service's job\",\nwhich is a different fact from \"no such thing\", and the message names where\nthe job IS done.",
|
||||
})
|
||||
zip.Describe("PATCH /v1/tracker/projects/:key/issues/:num", zip.Doc{
|
||||
Description: "Edits one issue in place and returns it — retitle it, rewrite its\nbody, move it between board columns, reprioritize, reassign, reschedule, or\nreplace its labels. Every field is optional: one the caller omits keeps its\nstored value, and `labels` REPLACES the set rather than adding to it.\n\n`startAt` and `dueAt` are the issue's place on the timeline, in unix seconds;\n0 clears one. They are validated as the interval they RESULT in, so moving\nonly the due date is still checked against the stored start — a due date\nbefore its start is 400, never a bar drawn backwards.\n\nThe issue's kind, source and git bindings are not editable here: they record\nwhere the work item came FROM, which is a fact about its origin rather than\nits current state.",
|
||||
Description: "Edits a work item — rename it, rewrite it, move it to another\ncolumn, or re-prioritise it. Absent fields are left alone.\n\nMOVING A CARD IS A RELABEL. The column lives in the forge's label set, so the\nmove replaces that set rather than writing a status column here that a\nforge-side change could contradict. Moving to `done` also CLOSES the issue on\nthe forge, because a done card and an open issue are a contradiction.",
|
||||
Fields: map[string]string{
|
||||
"issuePatch.assignee": "Assignee is who owns the issue, at most 256 characters. Empty unassigns it.",
|
||||
"issuePatch.description": "Description is the issue body, at most 32768 characters.",
|
||||
"issuePatch.dueAt": "DueAt is when the work is due, in unix seconds — the right edge of its\nbar, or the milestone marker when there is no start. 0 clears it. It may\nnot fall before startAt.",
|
||||
"issuePatch.key": "Key is the issue's project, from the path.",
|
||||
"issuePatch.labels": "Labels REPLACES the issue's labels with exactly this set. Each label is at\nmost 48 characters and may not contain a comma (the storage separator);\nempty entries are dropped.",
|
||||
"issuePatch.num": "Num is the issue's number within that project, from the path.",
|
||||
"issuePatch.priority": "Priority is none, urgent, high, medium or low. Empty resets it to none.",
|
||||
"issuePatch.startAt": "StartAt is when the work starts, in unix seconds — the left edge of its\nbar on the timeline. 0 clears it.",
|
||||
"issuePatch.status": "Status moves the issue between board columns: backlog, todo, in_progress,\ndone or canceled. Empty resets it to backlog.",
|
||||
"issuePatch.title": "Title is the issue's one-line summary. Non-empty, at most 512 characters.",
|
||||
"issueView.dueAt": "unix seconds; absent = no due date",
|
||||
"issueView.extRef": "external anchor",
|
||||
"issueView.identifier": "KEY-<number>, the human handle",
|
||||
"issueView.kind": "issue | pr | epic",
|
||||
"issueView.repo": "git repo binding",
|
||||
"issueView.source": "team | git | crm | helpdesk | cms | agent",
|
||||
"issueView.startAt": "unix seconds; absent = unscheduled",
|
||||
"issueEdit.description": "Description rewrites the body.",
|
||||
"issueEdit.key": "Key is the board — the repository name, from the path.",
|
||||
"issueEdit.num": "Num is the issue number on that repository, from the path.",
|
||||
"issueEdit.priority": "Priority re-prioritises it.",
|
||||
"issueEdit.status": "Status moves the card to another column.",
|
||||
"issueEdit.title": "Title renames the work item.",
|
||||
"issueView.dueAt": "unix seconds; absent = no due date",
|
||||
"issueView.extRef": "external anchor",
|
||||
"issueView.identifier": "KEY-<number>, the human handle",
|
||||
"issueView.kind": "issue | pr | epic",
|
||||
"issueView.repo": "git repo binding",
|
||||
"issueView.source": "team | git | crm | helpdesk | cms | agent",
|
||||
"issueView.startAt": "unix seconds; absent = unscheduled",
|
||||
},
|
||||
Example: json.RawMessage(`{"key":"ENG","num":14,"status":"in_progress","assignee":"z"}`),
|
||||
})
|
||||
zip.Describe("POST /tracker/upsert", zip.Doc{
|
||||
Description: "Mirrors one external work item into the CALLER's org — creating the\nrow, or updating the one already carrying that ExtRef — and reports which it did\nplus the tracker identity the item is now known by.\n\nThe org is the caller's plane identity and never the argument — plane.IssueIn has\nno org field, deliberately, because a feeder able to state the org could file\ninto another tenant's tracker. Anonymous is refused rather than defaulted: an\nitem arriving with no principal must fail, not land on somebody's board.\n\nIt calls upsertIssue, never cloud.UpsertIssue. cloud.UpsertIssue now falls\nthrough to THIS op when the local sink is nil, so a process serving it that went\nback through it would dial its own socket and ask itself, forever.\n\nA named handler, not a closure, so zipdoc can lift this prose into the registry.",
|
||||
@@ -113,9 +78,23 @@ func init() {
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/tracker/projects", zip.Doc{
|
||||
Description: "Binds a Service-scoped handler to a route: it adapts a\n`func(*Service[S], *zip.Ctx) error` to the plain `func(*zip.Ctx) error` the\nrouter takes, capturing s. One adapter, so packages write free-function\nhandlers and register them with `app.Get(\"/path\", cloud.Handle(s, myHandler))`.",
|
||||
Description: "Refuses to create, rename or delete a board.\n\nA board IS a repository on the forge. Its lifecycle is a forge operation with\nforge permissions, and offering a second door onto it here would mean this\nsurface's guard, not the forge's, decided who may make and destroy\nrepositories — a weaker guard on the same object.\n\n405 and not 404: the route exists and the answer is \"not this service's job\",\nwhich is a different fact from \"no such thing\", and the message names where\nthe job IS done.",
|
||||
})
|
||||
zip.Describe("POST /v1/tracker/projects/:key/issues", zip.Doc{
|
||||
Description: "Binds a Service-scoped handler to a route: it adapts a\n`func(*Service[S], *zip.Ctx) error` to the plain `func(*zip.Ctx) error` the\nrouter takes, capturing s. One adapter, so packages write free-function\nhandlers and register them with `app.Get(\"/path\", cloud.Handle(s, myHandler))`.",
|
||||
Description: "Opens a work item on the board — an issue on that repository on\nthe deployment's forge, filed as YOU.\n\nThe column and priority are written as LABELS, which is what makes the card\nand the forge issue the same object: someone relabelling in the forge web UI\nhas moved your card.",
|
||||
Fields: map[string]string{
|
||||
"issueView.dueAt": "unix seconds; absent = no due date",
|
||||
"issueView.extRef": "external anchor",
|
||||
"issueView.identifier": "KEY-<number>, the human handle",
|
||||
"issueView.kind": "issue | pr | epic",
|
||||
"issueView.repo": "git repo binding",
|
||||
"issueView.source": "team | git | crm | helpdesk | cms | agent",
|
||||
"issueView.startAt": "unix seconds; absent = unscheduled",
|
||||
"newIssue.description": "Description becomes the issue body.",
|
||||
"newIssue.key": "Key is the board — the repository name, from the path.",
|
||||
"newIssue.priority": "Priority is one of none, urgent, high, medium or low.",
|
||||
"newIssue.status": "Status is the board column to open into: backlog, todo, in_progress, done\nor canceled. Empty opens into backlog.",
|
||||
"newIssue.title": "Title is required.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
+23
-6
@@ -67,8 +67,9 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/authz"
|
||||
"github.com/hanzoai/cloud/apps/principal"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -250,11 +251,23 @@ func actorFromCtx(c *zip.Ctx, home string) audit.Actor {
|
||||
//
|
||||
// WHY THE TWO ORGS DIFFER AT ALL. SanitizeIdentity mints X-User-Owner (the home
|
||||
// org, from the validated membership claim) DISTINCTLY from X-Org-Id (the
|
||||
// effective org). For every ordinary caller the two are equal. They diverge in
|
||||
// exactly one case: a HUMAN principal whose home org is the reserved admin org
|
||||
// switching into another tenant (middleware_identity.go, `effOrg = cliOrg`).
|
||||
// That divergence IS the impersonation, so comparing the two detects it without
|
||||
// inventing a new signal or a new header.
|
||||
// effective org). For every single-org caller the two are equal.
|
||||
//
|
||||
// DIVERGENCE ALONE IS NO LONGER IMPERSONATION. It once was: the only principal
|
||||
// who could act under an org that was not their home org was a SuperAdmin
|
||||
// switching in, so `home != eff` was exactly the impersonation signal. Since
|
||||
// membership-based org switching landed, ANY ordinary member of two orgs
|
||||
// diverges the moment they work in their second one — which is a normal Tuesday,
|
||||
// not an impersonation. Reading divergence as impersonation therefore did two
|
||||
// harmful things: it told auditors that routine multi-org work was a platform
|
||||
// admin acting inside a tenant, and it wrote a Home-bearing row for every
|
||||
// switched request including plain 200 GETs, burying the real events in volume.
|
||||
//
|
||||
// So the predicate is the SPECIFIC fact it always meant to capture: the actor's
|
||||
// home is the RESERVED ADMIN ORG (authz.AdminOrg — the issuer's constant, the
|
||||
// same one IAM's store.IsSuperAdmin reads, never a local knob that could
|
||||
// disagree with the token contract). A non-empty Home is once again, by
|
||||
// construction, a platform SuperAdmin acting inside another tenant.
|
||||
//
|
||||
// UNFORGEABLE BY CONSTRUCTION. Both values are authorityHeaders: stripped from
|
||||
// every inbound request and re-minted only from validated claims. So an attacker
|
||||
@@ -272,6 +285,10 @@ func crossOrgHome(c *zip.Ctx) string {
|
||||
if home == "" || eff == "" || home == eff {
|
||||
return ""
|
||||
}
|
||||
// The divergence must be a SuperAdmin's, not an ordinary multi-org member's.
|
||||
if home != authz.AdminOrg {
|
||||
return ""
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
|
||||
+651
@@ -0,0 +1,651 @@
|
||||
// Package forge is the client for the deployment's code forge — the Forgejo
|
||||
// instance (git.hanzo.ai beside api.hanzo.ai) that holds the estate's issues and
|
||||
// milestones.
|
||||
//
|
||||
// It exists because those work items have ONE home. The forge is where an issue
|
||||
// is filed, labelled, assigned and closed; a second copy in another store would
|
||||
// be a second answer to "what is the state of this work", and the two would
|
||||
// drift. So nothing here caches, mirrors or writes through to a local table:
|
||||
// every read is a read OF the forge, and every write is a write TO it.
|
||||
//
|
||||
// # The wire
|
||||
//
|
||||
// The forge answers its REST API at /v1 — NOT /api/v1, which 404s. That is the
|
||||
// one fact most likely to be mis-remembered from upstream Gitea documentation,
|
||||
// so it is stated once, here, as [API], and never spelled again.
|
||||
//
|
||||
// # The two credentials, and why there is only one
|
||||
//
|
||||
// A caller of this package is a REQUEST from a user, but the credential is the
|
||||
// DEPLOYMENT's: one machine token, read from KMS (never an env file, never a
|
||||
// browser-side PAT, never a per-user OAuth grant this process would have to
|
||||
// custody). A per-user token would mean N secrets to rotate, revoke and leak;
|
||||
// one token means one.
|
||||
//
|
||||
// One token would ordinarily mean one identity, and therefore one permission
|
||||
// set — the machine's — applied to every user's request. That is the trap, and
|
||||
// [Client.As] is the way out: Forgejo's Sudo lets an authorized token ACT AS a
|
||||
// named user, and it DROPS PRIVILEGE to that user rather than merely relabelling
|
||||
// the actor. Measured against this deployment's forge:
|
||||
//
|
||||
// token alone, GET /v1/repos/hanzo-private/patents → 200
|
||||
// token + Sudo: deploy, GET /v1/repos/hanzo-private/patents → 404
|
||||
// anonymous, GET /v1/repos/hanzo-private/patents → 404
|
||||
//
|
||||
// The sudoed request is byte-identical to the anonymous one: the forge's own
|
||||
// ACL, not this client's good intentions, decided what came back. That is what
|
||||
// makes one credential safe to hold. It also makes a write ATTRIBUTABLE — the
|
||||
// issue's actor is the human, not a shared bot — which is the audit property a
|
||||
// shared machine identity otherwise destroys.
|
||||
//
|
||||
// # Fail closed
|
||||
//
|
||||
// Sudo is not optional on a user-facing call and there is no "unsudoed
|
||||
// fallback": [Client.As] with an empty login returns a client whose every call
|
||||
// refuses ([ErrNoActor]), because falling back to the raw machine token on a
|
||||
// missing actor is exactly the escalation this design exists to prevent. A
|
||||
// login the forge does not know 404s, which surfaces as [ErrUnknownActor]
|
||||
// rather than an empty result — an empty board and "you have no forge account"
|
||||
// are different answers and must not be spelled the same way.
|
||||
package forge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// API is the forge's REST prefix.
|
||||
//
|
||||
// It is /v1 and not /api/v1: this deployment's forge serves the API at the apex
|
||||
// of the host it owns, and /api/v1 answers 404. Stated once so no call site
|
||||
// re-derives it from upstream docs.
|
||||
const API = "/v1"
|
||||
|
||||
// Name is what the forge answers to among the sibling hosts a deployment owns,
|
||||
// for brand.Sibling(domain, forge.Name) — the ONE derivation of the forge host
|
||||
// from the deployment's own domain. Spelling "git.hanzo.ai" into a config is
|
||||
// what makes a white-labelled deployment (lux.network, zoo.ngo) talk to another
|
||||
// brand's forge.
|
||||
const Name = "git"
|
||||
|
||||
// page is the forge's max page size for a paginated list. Fixed by the server;
|
||||
// asking for more returns this many anyway.
|
||||
const page = 50
|
||||
|
||||
// maxPages bounds every pagination loop. A forge that keeps answering full pages
|
||||
// — buggy, or hostile after a compromise — must not spin this process forever.
|
||||
// 50 pages x 50 items is 2,500 repos or issues, past any real org.
|
||||
const maxPages = 50
|
||||
|
||||
// maxBody bounds a single response read. The forge is a trusted service, but
|
||||
// "trusted" is a statement about intent and not about compromise, and an
|
||||
// unbounded io.ReadAll on a remote body is an OOM one bad response away.
|
||||
const maxBody = 32 << 20 // 32 MiB
|
||||
|
||||
// fanout bounds the concurrent per-repo requests an org rollup makes. The forge
|
||||
// has no org-level milestones API, so a rollup is N repo calls; unbounded, a
|
||||
// large org would open hundreds of sockets at once and the rollup would read as
|
||||
// a denial-of-service against our own forge.
|
||||
const fanout = 8
|
||||
|
||||
// Errors a caller must be able to tell apart. They are distinguished because the
|
||||
// right answer differs: no actor is a bug in the CALLER (it forgot to scope),
|
||||
// an unknown actor is a fact about the USER (no forge identity), and neither is
|
||||
// "the board is empty".
|
||||
var (
|
||||
// ErrNoActor is returned by every call on a client with no Sudo actor. It is
|
||||
// a refusal, never a fallback to the machine identity.
|
||||
ErrNoActor = errors.New("forge: no actor — a user-facing call must be scoped with As()")
|
||||
|
||||
// ErrUnknownActor means the forge does not know the actor we sudoed as.
|
||||
ErrUnknownActor = errors.New("forge: unknown actor — no forge identity for this user")
|
||||
|
||||
// ErrNoToken means the machine credential is absent or empty.
|
||||
ErrNoToken = errors.New("forge: no service token")
|
||||
)
|
||||
|
||||
// Client talks to one forge as one actor.
|
||||
//
|
||||
// The zero Client is unusable; build one with [New]. A Client is safe for
|
||||
// concurrent use, and [Client.As] derives a per-request actor cheaply (it copies
|
||||
// a struct and shares the transport) so a request handler never mutates a shared
|
||||
// one — mutating a shared actor is a cross-user attribution race, and the type
|
||||
// is shaped so that it cannot be written.
|
||||
type Client struct {
|
||||
base string // scheme://host/v1
|
||||
token string // machine credential from KMS — NEVER logged
|
||||
actor string // Forgejo Sudo login; empty ⇒ every call refuses
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New builds a client for the forge at `host` authenticating with `token`.
|
||||
//
|
||||
// host is a bare host (git.hanzo.ai) or a full origin; token is the machine
|
||||
// credential, which the caller reads from KMS. An empty token is not deferred to
|
||||
// the first call — a client that cannot authenticate is a configuration error
|
||||
// and says so at construction.
|
||||
func New(host, token string) (*Client, error) {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return nil, errors.New("forge: empty host")
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return nil, ErrNoToken
|
||||
}
|
||||
if !strings.Contains(host, "://") {
|
||||
host = "https://" + host
|
||||
}
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("forge: bad host: %w", err)
|
||||
}
|
||||
if u.Scheme != "https" && u.Hostname() != "localhost" && u.Hostname() != "127.0.0.1" {
|
||||
// The machine token rides every request. Sending it over cleartext to a
|
||||
// remote host puts it on the wire for anyone on the path; loopback is
|
||||
// exempt so tests and a local forge work without a certificate.
|
||||
return nil, fmt.Errorf("forge: refusing non-https host %q", u.Host)
|
||||
}
|
||||
return &Client{
|
||||
base: strings.TrimSuffix(u.Scheme+"://"+u.Host, "/") + API,
|
||||
token: strings.TrimSpace(token),
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// As returns a client that acts as the forge user `login`, dropping privilege to
|
||||
// that user's own permissions for every call made through it (see the package
|
||||
// comment for the measurement).
|
||||
//
|
||||
// The receiver is not modified: the returned client is a copy sharing the same
|
||||
// transport, so concurrent requests each hold their own actor and no two can
|
||||
// interleave. A blank login yields a client that REFUSES rather than one that
|
||||
// falls back to the machine identity.
|
||||
func (c *Client) As(login string) *Client {
|
||||
cp := *c
|
||||
cp.actor = strings.TrimSpace(login)
|
||||
return &cp
|
||||
}
|
||||
|
||||
// Actor is the forge login this client acts as, empty if unscoped. For logs and
|
||||
// errors — the actor is an identity, not a credential, and is safe to record.
|
||||
func (c *Client) Actor() string { return c.actor }
|
||||
|
||||
// do issues one authenticated, sudoed GET and decodes JSON into out.
|
||||
//
|
||||
// It is the ONE place the credential is attached and the ONE place Sudo is
|
||||
// enforced, so neither can be forgotten by a call site: every method below is
|
||||
// written in terms of this.
|
||||
func (c *Client) do(ctx context.Context, path string, q url.Values, out any) error {
|
||||
if c.actor == "" {
|
||||
return ErrNoActor
|
||||
}
|
||||
if c.token == "" {
|
||||
return ErrNoToken
|
||||
}
|
||||
u := c.base + path
|
||||
if len(q) > 0 {
|
||||
u += "?" + q.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("forge: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
// Sudo as a HEADER, never as a ?sudo= query parameter. Both work, but a query
|
||||
// parameter lands in access logs and proxy traces, so the actor of every
|
||||
// request would be written into logs the forge and every hop keep. The header
|
||||
// form keeps attribution out of URL telemetry.
|
||||
req.Header.Set("Sudo", c.actor)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
// The URL is safe to surface (it names a path, not a secret) but the error
|
||||
// from the transport can embed the request URL only — never a header — so
|
||||
// the token cannot ride out in an error string.
|
||||
return fmt.Errorf("forge: GET %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusNotFound:
|
||||
// With Sudo set, the forge answers 404 both for "the actor does not exist"
|
||||
// and for "this actor cannot see that". Neither is an error the caller can
|
||||
// fix by retrying, and both must read as "no access", never as an empty
|
||||
// success — a 404 rendered as an empty list is how a board silently lies.
|
||||
return fmt.Errorf("%w: %s", ErrUnknownActor, c.actor)
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return fmt.Errorf("forge: %s: credential rejected (%d)", path, resp.StatusCode)
|
||||
default:
|
||||
return fmt.Errorf("forge: %s: unexpected status %d", path, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("forge: read %s: %w", path, err)
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("forge: decode %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── the wire shapes ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Only the fields the tracker renders are declared. A struct that mirrored every
|
||||
// forge field would be a second schema to maintain against an upstream we do not
|
||||
// control, and would publish fields our surface never promised.
|
||||
|
||||
// Repo is one repository under an org.
|
||||
type Repo struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Private bool `json:"private"`
|
||||
Archived bool `json:"archived"`
|
||||
Open int `json:"open_issues_count"`
|
||||
}
|
||||
|
||||
// User is a forge account, as an issue's author or assignee.
|
||||
type User struct {
|
||||
Login string `json:"login"`
|
||||
Avatar string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
// Label is a forge label. It carries the board's column: the tracker's status is
|
||||
// a label set on the forge, not a column in a table here (see [Issues]).
|
||||
type Label struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// Milestone is a forge milestone, always repo-scoped — the forge has no
|
||||
// org-level milestone. [Client.Milestones] is the org rollup.
|
||||
type Milestone struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
Open int `json:"open_issues"`
|
||||
Closed int `json:"closed_issues"`
|
||||
Due string `json:"due_on,omitempty"`
|
||||
|
||||
// Repo is the repository this milestone belongs to. The forge does not send
|
||||
// it — a repo-scoped list has no reason to — and the rollup fills it in, so a
|
||||
// caller merging N repos' milestones can still tell them apart.
|
||||
Repo string `json:"repo"`
|
||||
}
|
||||
|
||||
// Issue is one work item. The forge's issues-search answers labels, milestone
|
||||
// and assignees INLINE, so a board renders from one request rather than one
|
||||
// request per card.
|
||||
type Issue struct {
|
||||
ID int64 `json:"id"`
|
||||
Number int64 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
State string `json:"state"`
|
||||
URL string `json:"html_url"`
|
||||
Labels []Label `json:"labels"`
|
||||
Milestone *Milestone `json:"milestone,omitempty"`
|
||||
User *User `json:"user,omitempty"`
|
||||
Assignees []User `json:"assignees"`
|
||||
Created string `json:"created_at"`
|
||||
Updated string `json:"updated_at"`
|
||||
|
||||
// PullRequest is non-nil when the row is a PR rather than an issue. The forge
|
||||
// returns both from one search; the tracker's Kind is read from this.
|
||||
PullRequest *struct {
|
||||
Merged bool `json:"merged"`
|
||||
} `json:"pull_request,omitempty"`
|
||||
|
||||
// Repository names the repo the issue lives in. Present on issues-search
|
||||
// (which spans repos) and the only way to address the issue afterwards.
|
||||
Repository *struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Owner string `json:"owner"`
|
||||
} `json:"repository,omitempty"`
|
||||
}
|
||||
|
||||
// ── the reads ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Repos lists the repositories of `org` that this client's actor can see.
|
||||
//
|
||||
// The actor's own visibility is what bounds the answer: a user who is not a
|
||||
// member of a private org gets that org's public repos and nothing else, decided
|
||||
// by the forge rather than by a filter here.
|
||||
func (c *Client) Repos(ctx context.Context, org string) ([]Repo, error) {
|
||||
if err := validOrg(org); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var all []Repo
|
||||
for p := 1; p <= maxPages; p++ {
|
||||
var batch []Repo
|
||||
q := url.Values{"limit": {strconv.Itoa(page)}, "page": {strconv.Itoa(p)}}
|
||||
if err := c.do(ctx, "/orgs/"+url.PathEscape(org)+"/repos", q, &batch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, batch...)
|
||||
if len(batch) < page {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// IssueFilter narrows an issue search. Every field is OPTIONAL and none of them
|
||||
// carries tenancy: the org is a separate, non-optional argument to [Client.Issues]
|
||||
// precisely so it can never arrive as part of a caller-supplied filter.
|
||||
type IssueFilter struct {
|
||||
// State is "open", "closed" or "all". Empty means the forge's default (open).
|
||||
State string
|
||||
// Labels selects issues carrying ALL of these labels.
|
||||
Labels []string
|
||||
// Milestone selects issues in a milestone, by title.
|
||||
Milestone string
|
||||
// Type is "issues" or "pulls"; empty returns both.
|
||||
Type string
|
||||
// Limit caps the rows returned. Zero means every page up to [maxPages].
|
||||
Limit int
|
||||
}
|
||||
|
||||
// Issues searches every repo of `org` the actor can see, in one call per page.
|
||||
//
|
||||
// The forge's /repos/issues/search answers labels, milestone, assignees and the
|
||||
// owning repository inline, which is what lets the board render columns without
|
||||
// an N+1 walk: a column is a label, a card is one of these rows, and moving a
|
||||
// card is a relabel of the same row.
|
||||
func (c *Client) Issues(ctx context.Context, org string, f IssueFilter) ([]Issue, error) {
|
||||
if err := validOrg(org); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var all []Issue
|
||||
for p := 1; p <= maxPages; p++ {
|
||||
lim := page
|
||||
if f.Limit > 0 && f.Limit-len(all) < lim {
|
||||
lim = f.Limit - len(all)
|
||||
}
|
||||
if lim <= 0 {
|
||||
break
|
||||
}
|
||||
q := url.Values{
|
||||
// owner is the TENANCY of this call. It is set from the org argument,
|
||||
// which every caller resolves from a validated principal — never from a
|
||||
// filter field, and never from a request body.
|
||||
"owner": {org},
|
||||
"limit": {strconv.Itoa(lim)},
|
||||
"page": {strconv.Itoa(p)},
|
||||
}
|
||||
if f.State != "" {
|
||||
q.Set("state", f.State)
|
||||
}
|
||||
if len(f.Labels) > 0 {
|
||||
q.Set("labels", strings.Join(f.Labels, ","))
|
||||
}
|
||||
if f.Milestone != "" {
|
||||
q.Set("milestones", f.Milestone)
|
||||
}
|
||||
if f.Type != "" {
|
||||
q.Set("type", f.Type)
|
||||
}
|
||||
var batch []Issue
|
||||
if err := c.do(ctx, "/repos/issues/search", q, &batch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, batch...)
|
||||
if len(batch) < lim {
|
||||
break
|
||||
}
|
||||
if f.Limit > 0 && len(all) >= f.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// Milestones is the ORG ROLLUP the forge does not offer.
|
||||
//
|
||||
// Forgejo scopes milestones to a repository and publishes no org-level list, so
|
||||
// the rollup is a fan-out: list the org's repos, ask each for its milestones,
|
||||
// merge. It runs HERE, server-side, rather than in the browser, for three
|
||||
// reasons — a client-side fan-out would issue N cross-origin requests per board
|
||||
// load, would need the forge reachable from the browser (and therefore a
|
||||
// browser-held credential, which is the thing this design refuses), and would
|
||||
// make the actor's visibility a client-side filter instead of a server-side ACL.
|
||||
//
|
||||
// Concurrency is bounded by [fanout], and one repo's failure fails the rollup:
|
||||
// a milestone list silently missing the repos that errored is a wrong answer
|
||||
// presented as a complete one.
|
||||
func (c *Client) Milestones(ctx context.Context, org string) ([]Milestone, error) {
|
||||
repos, err := c.Repos(ctx, org)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Cancel the remaining fan-out as soon as one leg fails; without this a large
|
||||
// org keeps issuing requests whose result is already discarded.
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
out []Milestone
|
||||
bad error
|
||||
sem = make(chan struct{}, fanout)
|
||||
wg sync.WaitGroup
|
||||
once sync.Once
|
||||
)
|
||||
for _, r := range repos {
|
||||
if r.Archived {
|
||||
continue // an archived repo's milestones are not live work
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(name string) {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
var ms []Milestone
|
||||
q := url.Values{"state": {"all"}, "limit": {strconv.Itoa(page)}}
|
||||
path := "/repos/" + url.PathEscape(org) + "/" + url.PathEscape(name) + "/milestones"
|
||||
if err := c.do(ctx, path, q, &ms); err != nil {
|
||||
once.Do(func() {
|
||||
mu.Lock()
|
||||
bad = fmt.Errorf("milestones %s/%s: %w", org, name, err)
|
||||
mu.Unlock()
|
||||
cancel()
|
||||
})
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
for i := range ms {
|
||||
ms[i].Repo = name
|
||||
out = append(out, ms[i])
|
||||
}
|
||||
mu.Unlock()
|
||||
}(r.Name)
|
||||
}
|
||||
wg.Wait()
|
||||
if bad != nil {
|
||||
return nil, bad
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── the writes ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every write is made under the caller's Sudo actor, so the forge records the
|
||||
// HUMAN as the author of the issue and of each label change — not a shared bot.
|
||||
// That is the audit property a machine identity would destroy, and it is the
|
||||
// reason the write path is worth building on Sudo rather than on a second
|
||||
// per-user credential.
|
||||
|
||||
// send issues one authenticated, sudoed request carrying a JSON body.
|
||||
type sendOpts struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
out any
|
||||
}
|
||||
|
||||
func (c *Client) send(ctx context.Context, o sendOpts) error {
|
||||
if c.actor == "" {
|
||||
return ErrNoActor
|
||||
}
|
||||
if c.token == "" {
|
||||
return ErrNoToken
|
||||
}
|
||||
var rdr io.Reader
|
||||
if o.body != nil {
|
||||
b, err := json.Marshal(o.body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("forge: encode body: %w", err)
|
||||
}
|
||||
rdr = strings.NewReader(string(b))
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, o.method, c.base+o.path, rdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("forge: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
req.Header.Set("Sudo", c.actor)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if o.body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("forge: %s %s: %w", o.method, o.path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return fmt.Errorf("%w: %s", ErrUnknownActor, c.actor)
|
||||
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
|
||||
// A sudoed write the ACTOR may not make lands here. It is the forge
|
||||
// enforcing its own ACL on the human, which is the point of Sudo.
|
||||
return fmt.Errorf("forge: %s %s: refused (%d)", o.method, o.path, resp.StatusCode)
|
||||
case resp.StatusCode >= 300:
|
||||
return fmt.Errorf("forge: %s %s: unexpected status %d", o.method, o.path, resp.StatusCode)
|
||||
}
|
||||
if o.out == nil {
|
||||
return nil
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("forge: read %s: %w", o.path, err)
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(body, o.out); err != nil {
|
||||
return fmt.Errorf("forge: decode %s: %w", o.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewIssue is the issue to open. Labels carry the board column, so a card
|
||||
// created into a column is one call.
|
||||
type NewIssue struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
// CreateIssue opens an issue on org/repo as the actor.
|
||||
func (c *Client) CreateIssue(ctx context.Context, org, repo string, n NewIssue) (Issue, error) {
|
||||
if err := validOrg(org); err != nil {
|
||||
return Issue{}, err
|
||||
}
|
||||
if err := validOrg(repo); err != nil {
|
||||
return Issue{}, fmt.Errorf("forge: repo: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(n.Title) == "" {
|
||||
return Issue{}, errors.New("forge: empty title")
|
||||
}
|
||||
var out Issue
|
||||
err := c.send(ctx, sendOpts{
|
||||
method: http.MethodPost,
|
||||
path: "/repos/" + url.PathEscape(org) + "/" + url.PathEscape(repo) + "/issues",
|
||||
body: n, out: &out,
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// IssuePatch changes an issue. A nil field is left alone — the forge treats an
|
||||
// absent key as "unchanged", so this must not marshal zero values.
|
||||
// Labels are NOT here: they move through [Client.SetLabels], which REPLACES the
|
||||
// set, because the board's column is a label and "move this card" must be one
|
||||
// unambiguous operation rather than a field on a general-purpose patch.
|
||||
type IssuePatch struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Body *string `json:"body,omitempty"`
|
||||
State *string `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
// PatchIssue edits an issue's fields as the actor.
|
||||
func (c *Client) PatchIssue(ctx context.Context, org, repo string, number int64, p IssuePatch) error {
|
||||
if err := validOrg(org); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validOrg(repo); err != nil {
|
||||
return fmt.Errorf("forge: repo: %w", err)
|
||||
}
|
||||
return c.send(ctx, sendOpts{
|
||||
method: http.MethodPatch,
|
||||
path: fmt.Sprintf("/repos/%s/%s/issues/%d", url.PathEscape(org), url.PathEscape(repo), number),
|
||||
body: p,
|
||||
})
|
||||
}
|
||||
|
||||
// SetLabels REPLACES an issue's label set, which is how a card moves between
|
||||
// columns: the column is a label, so moving it is a relabel and not an update to
|
||||
// a status column that a forge-side change could contradict.
|
||||
func (c *Client) SetLabels(ctx context.Context, org, repo string, number int64, labels []string) error {
|
||||
if err := validOrg(org); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validOrg(repo); err != nil {
|
||||
return fmt.Errorf("forge: repo: %w", err)
|
||||
}
|
||||
if labels == nil {
|
||||
labels = []string{}
|
||||
}
|
||||
return c.send(ctx, sendOpts{
|
||||
method: http.MethodPut,
|
||||
path: fmt.Sprintf("/repos/%s/%s/issues/%d/labels", url.PathEscape(org), url.PathEscape(repo), number),
|
||||
body: map[string]any{"labels": labels},
|
||||
})
|
||||
}
|
||||
|
||||
// validOrg refuses an org that is empty or not a forge path segment.
|
||||
//
|
||||
// The org reaches this package from a validated principal, so a bad value is a
|
||||
// bug rather than an attack — but it is interpolated into a URL PATH, and a
|
||||
// value bearing "/" or ".." would address a different endpoint than the one the
|
||||
// call site wrote. Refusing here means no call site can be the place that
|
||||
// forgot.
|
||||
func validOrg(org string) error {
|
||||
if strings.TrimSpace(org) == "" {
|
||||
return errors.New("forge: empty org")
|
||||
}
|
||||
if org != strings.TrimSpace(org) {
|
||||
return fmt.Errorf("forge: org %q has surrounding space", org)
|
||||
}
|
||||
if strings.ContainsAny(org, "/\\?#%") || strings.Contains(org, "..") {
|
||||
return fmt.Errorf("forge: org %q is not a path segment", org)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
package forge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seen records what actually reached the wire. Every security property this
|
||||
// package claims is a property of the REQUEST it emits, so the tests assert on
|
||||
// the request rather than on the returned value: a client that returns the right
|
||||
// issues while leaking the machine identity has still failed.
|
||||
type seen struct {
|
||||
mu sync.Mutex
|
||||
reqs []*http.Request
|
||||
}
|
||||
|
||||
func (s *seen) add(r *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
c := r.Clone(context.Background())
|
||||
s.reqs = append(s.reqs, c)
|
||||
}
|
||||
|
||||
func (s *seen) all() []*http.Request {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]*http.Request(nil), s.reqs...)
|
||||
}
|
||||
|
||||
// forgeStub is a fake forge that enforces the REAL forge's sudo semantics, which
|
||||
// this package's whole safety argument rests on: a sudoed request sees only what
|
||||
// that user may see, and an unknown sudo user is 404. Measured against the live
|
||||
// forge (see the package comment) before being written down here.
|
||||
type forgeStub struct {
|
||||
*httptest.Server
|
||||
got *seen
|
||||
|
||||
// visible maps a forge login to the orgs that user may see. A user absent
|
||||
// from the map does not exist and every request sudoing as them is 404.
|
||||
visible map[string][]string
|
||||
// issues and milestones are keyed by org and repo respectively.
|
||||
issues map[string][]Issue
|
||||
repos map[string][]Repo
|
||||
milestones map[string][]Milestone
|
||||
token string
|
||||
}
|
||||
|
||||
func newStub(t *testing.T) *forgeStub {
|
||||
t.Helper()
|
||||
s := &forgeStub{
|
||||
got: &seen{},
|
||||
visible: map[string][]string{},
|
||||
issues: map[string][]Issue{},
|
||||
repos: map[string][]Repo{},
|
||||
milestones: map[string][]Milestone{},
|
||||
token: "machine-token-value",
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.got.add(r)
|
||||
|
||||
if r.Header.Get("Authorization") != "token "+s.token {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
actor := r.Header.Get("Sudo")
|
||||
orgs, known := s.visible[actor]
|
||||
if !known {
|
||||
// The real forge's answer for an unknown sudo user.
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
canSee := func(org string) bool {
|
||||
for _, o := range orgs {
|
||||
if o == org {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/v1")
|
||||
switch {
|
||||
case path == "/repos/issues/search":
|
||||
org := r.URL.Query().Get("owner")
|
||||
if !canSee(org) {
|
||||
// The forge answers an empty set for an org the actor cannot see,
|
||||
// rather than 404 — which is exactly why cloud must not rely on the
|
||||
// status code alone for tenancy.
|
||||
writeJSON(w, []Issue{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, pageOf(s.issues[org], r))
|
||||
case strings.HasSuffix(path, "/repos") && strings.HasPrefix(path, "/orgs/"):
|
||||
org := strings.TrimSuffix(strings.TrimPrefix(path, "/orgs/"), "/repos")
|
||||
if !canSee(org) {
|
||||
writeJSON(w, []Repo{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, pageOf(s.repos[org], r))
|
||||
case strings.HasSuffix(path, "/milestones"):
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/repos/"), "/")
|
||||
if len(parts) < 2 || !canSee(parts[0]) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, s.milestones[parts[0]+"/"+parts[1]])
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
s.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(s.Server.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// pageOf applies the forge's limit/page paging to a slice.
|
||||
func pageOf[T any](all []T, r *http.Request) []T {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = page
|
||||
}
|
||||
pg, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if pg <= 0 {
|
||||
pg = 1
|
||||
}
|
||||
start := (pg - 1) * limit
|
||||
if start >= len(all) {
|
||||
return nil
|
||||
}
|
||||
end := min(start+limit, len(all))
|
||||
return all[start:end]
|
||||
}
|
||||
|
||||
// client builds a client against the stub. The stub is http, so it exercises the
|
||||
// loopback exemption in New deliberately.
|
||||
func (s *forgeStub) client(t *testing.T) *Client {
|
||||
t.Helper()
|
||||
c, err := New(s.URL, s.token)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ── construction: the credential cannot be absent or in the clear ────────────
|
||||
|
||||
func TestNew_RefusesUnusableConfiguration(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, host, token string
|
||||
want error
|
||||
}{
|
||||
{name: "no token", host: "https://git.hanzo.ai", token: "", want: ErrNoToken},
|
||||
{name: "blank token", host: "https://git.hanzo.ai", token: " ", want: ErrNoToken},
|
||||
{name: "no host", host: "", token: "t"},
|
||||
{name: "cleartext remote host", host: "http://git.hanzo.ai", token: "t"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, err := New(tc.host, tc.token)
|
||||
if err == nil {
|
||||
t.Fatalf("New(%q) succeeded, want refusal; client=%+v", tc.host, c)
|
||||
}
|
||||
if tc.want != nil && !errors.Is(err, tc.want) {
|
||||
t.Fatalf("err = %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_BareHostBecomesHTTPSAndCarriesTheV1Prefix(t *testing.T) {
|
||||
c, err := New("git.hanzo.ai", "t")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
// /v1 and never /api/v1 — the one wire fact this package pins.
|
||||
if want := "https://git.hanzo.ai/v1"; c.base != want {
|
||||
t.Fatalf("base = %q, want %q", c.base, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the fail-closed property: no actor is a refusal, never the machine ───────
|
||||
|
||||
// A client with no actor must refuse EVERY call. This is the single most
|
||||
// important property in the package: if an unscoped client fell back to the raw
|
||||
// machine token, one forgotten As() would read every private repo on the forge
|
||||
// with admin-equivalent rights.
|
||||
func TestNoActor_EveryCallRefusesAndNothingReachesTheWire(t *testing.T) {
|
||||
s := newStub(t)
|
||||
s.visible["alice"] = []string{"acme"}
|
||||
s.issues["acme"] = []Issue{{Number: 1, Title: "hello"}}
|
||||
c := s.client(t)
|
||||
|
||||
calls := map[string]func() error{
|
||||
"Issues": func() error { _, err := c.Issues(t.Context(), "acme", IssueFilter{}); return err },
|
||||
"Repos": func() error { _, err := c.Repos(t.Context(), "acme"); return err },
|
||||
"Milestones": func() error { _, err := c.Milestones(t.Context(), "acme"); return err },
|
||||
}
|
||||
for name, call := range calls {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := call(); !errors.Is(err, ErrNoActor) {
|
||||
t.Fatalf("%s with no actor: err = %v, want ErrNoActor", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if n := len(s.got.all()); n != 0 {
|
||||
t.Fatalf("%d requests reached the forge from an unscoped client; want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// As("") must not silently produce a working machine-identity client.
|
||||
func TestAs_BlankLoginStaysRefusing(t *testing.T) {
|
||||
s := newStub(t)
|
||||
c := s.client(t).As(" ")
|
||||
if _, err := c.Issues(t.Context(), "acme", IssueFilter{}); !errors.Is(err, ErrNoActor) {
|
||||
t.Fatalf("As(blank): err = %v, want ErrNoActor", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── what actually reaches the wire ───────────────────────────────────────────
|
||||
|
||||
func TestWire_CredentialAndActorAreSentCorrectly(t *testing.T) {
|
||||
s := newStub(t)
|
||||
s.visible["alice"] = []string{"acme"}
|
||||
s.issues["acme"] = []Issue{{Number: 1}}
|
||||
|
||||
if _, err := s.client(t).As("alice").Issues(t.Context(), "acme", IssueFilter{}); err != nil {
|
||||
t.Fatalf("Issues: %v", err)
|
||||
}
|
||||
reqs := s.got.all()
|
||||
if len(reqs) == 0 {
|
||||
t.Fatal("no request reached the forge")
|
||||
}
|
||||
r := reqs[0]
|
||||
if got := r.Header.Get("Authorization"); got != "token "+s.token {
|
||||
t.Fatalf("Authorization = %q, want the machine token", got)
|
||||
}
|
||||
if got := r.Header.Get("Sudo"); got != "alice" {
|
||||
t.Fatalf("Sudo = %q, want alice", got)
|
||||
}
|
||||
// The actor must NOT ride in the query string, where it lands in access logs
|
||||
// and proxy traces on every hop.
|
||||
if v := r.URL.Query().Get("sudo"); v != "" {
|
||||
t.Fatalf("actor leaked into the query string as sudo=%q", v)
|
||||
}
|
||||
// The tenancy of the call is the owner parameter, and it must equal the org
|
||||
// argument exactly.
|
||||
if got := r.URL.Query().Get("owner"); got != "acme" {
|
||||
t.Fatalf("owner = %q, want acme", got)
|
||||
}
|
||||
// /v1, never /api/v1.
|
||||
if !strings.HasPrefix(r.URL.Path, "/v1/") || strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
t.Fatalf("path = %q, want a /v1 path", r.URL.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// The credential must never appear in an error string — errors are logged, and a
|
||||
// logged token is a leaked token.
|
||||
func TestErrors_NeverCarryTheCredential(t *testing.T) {
|
||||
s := newStub(t)
|
||||
s.visible["alice"] = []string{"acme"}
|
||||
c := s.client(t)
|
||||
|
||||
_, errNoActor := c.Issues(t.Context(), "acme", IssueFilter{})
|
||||
_, errUnknown := c.As("nobody").Issues(t.Context(), "acme", IssueFilter{})
|
||||
_, errBadOrg := c.As("alice").Issues(t.Context(), "../admin", IssueFilter{})
|
||||
// A transport failure, which is the error most likely to embed the request.
|
||||
dead, err := New("https://127.0.0.1:1/", s.token)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
_, errDial := dead.As("alice").Issues(t.Context(), "acme", IssueFilter{})
|
||||
|
||||
for _, e := range []error{errNoActor, errUnknown, errBadOrg, errDial} {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(e.Error(), s.token) {
|
||||
t.Fatalf("error leaked the credential: %v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── tenancy: the org is an argument, and it is the only thing that scopes ─────
|
||||
|
||||
// The whole point of the design. Alice may see acme; she may not see umbrella.
|
||||
// Asking for umbrella with alice's actor returns umbrella's data ONLY if the
|
||||
// forge lets her have it — the client neither adds nor removes rows.
|
||||
func TestTenancy_ActorCannotReadAnotherOrgsIssues(t *testing.T) {
|
||||
s := newStub(t)
|
||||
s.visible["alice"] = []string{"acme"}
|
||||
s.visible["mallory"] = []string{"umbrella"}
|
||||
s.issues["acme"] = []Issue{{Number: 1, Title: "acme private work"}}
|
||||
s.issues["umbrella"] = []Issue{{Number: 7, Title: "umbrella secret"}}
|
||||
c := s.client(t)
|
||||
|
||||
// Mallory, aiming at acme, gets nothing — the forge refused her, not us.
|
||||
got, err := c.As("mallory").Issues(t.Context(), "acme", IssueFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Issues: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("mallory read %d of acme's issues: %+v", len(got), got)
|
||||
}
|
||||
|
||||
// Alice, aiming at acme, gets acme's work.
|
||||
got, err = c.As("alice").Issues(t.Context(), "acme", IssueFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Issues: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Title != "acme private work" {
|
||||
t.Fatalf("alice got %+v, want acme's one issue", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An IssueFilter must not be able to carry tenancy. This is a COMPILE-TIME
|
||||
// property — there is no Org field to set — and the test pins it so that adding
|
||||
// one becomes a deliberate act that breaks a named test rather than a quiet
|
||||
// convenience. A filter field would be caller-supplied, and a tenant key read
|
||||
// from caller-supplied data is a cross-tenant read the caller asserted for
|
||||
// itself (apps/tracker/typed.go states the same rule for In fields).
|
||||
func TestIssueFilter_CarriesNoTenancy(t *testing.T) {
|
||||
for _, forbidden := range []string{"Org", "Owner", "Tenant", "Repo", "Sudo", "Actor", "User"} {
|
||||
if fieldExists[IssueFilter](forbidden) {
|
||||
t.Fatalf("IssueFilter has a %s field: tenancy must be an argument, never a filter", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fieldExists[T any](name string) bool {
|
||||
rt := reflect.TypeFor[T]()
|
||||
for i := range rt.NumField() {
|
||||
if rt.Field(i).Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// An unknown actor is a distinct, explicit answer — never an empty board.
|
||||
func TestUnknownActor_IsAnErrorNotAnEmptyBoard(t *testing.T) {
|
||||
s := newStub(t)
|
||||
s.visible["alice"] = []string{"acme"}
|
||||
|
||||
got, err := s.client(t).As("ghost").Issues(t.Context(), "acme", IssueFilter{})
|
||||
if !errors.Is(err, ErrUnknownActor) {
|
||||
t.Fatalf("err = %v, want ErrUnknownActor", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("got %+v alongside the error; want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The org is interpolated into a URL path, so a value bearing a separator would
|
||||
// address an endpoint the call site did not write.
|
||||
func TestValidOrg_RefusesAnythingThatIsNotAPathSegment(t *testing.T) {
|
||||
s := newStub(t)
|
||||
s.visible["alice"] = []string{"acme"}
|
||||
c := s.client(t).As("alice")
|
||||
|
||||
for _, bad := range []string{
|
||||
"", " ", "acme/../admin", "acme/repos", "..", "a%2fb", "acme#frag", "acme?x=1", " acme",
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%q", bad), func(t *testing.T) {
|
||||
if _, err := c.Repos(t.Context(), bad); err == nil {
|
||||
t.Fatalf("Repos(%q) succeeded; want refusal", bad)
|
||||
}
|
||||
if _, err := c.Milestones(t.Context(), bad); err == nil {
|
||||
t.Fatalf("Milestones(%q) succeeded; want refusal", bad)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── the org rollup the forge does not offer ──────────────────────────────────
|
||||
|
||||
// Milestones is repo-scoped upstream, so the org view is a server-side fan-out.
|
||||
// It must cover every live repo, stamp each milestone with the repo it came
|
||||
// from, and skip archived repos.
|
||||
func TestMilestones_FansOutOverTheOrgsRepos(t *testing.T) {
|
||||
s := newStub(t)
|
||||
s.visible["alice"] = []string{"acme"}
|
||||
s.repos["acme"] = []Repo{
|
||||
{Name: "api", FullName: "acme/api"},
|
||||
{Name: "web", FullName: "acme/web"},
|
||||
{Name: "old", FullName: "acme/old", Archived: true},
|
||||
}
|
||||
s.milestones["acme/api"] = []Milestone{{ID: 1, Title: "v1", State: "open", Open: 3}}
|
||||
s.milestones["acme/web"] = []Milestone{{ID: 2, Title: "launch", State: "open", Open: 5}}
|
||||
s.milestones["acme/old"] = []Milestone{{ID: 3, Title: "ancient"}}
|
||||
|
||||
got, err := s.client(t).As("alice").Milestones(t.Context(), "acme")
|
||||
if err != nil {
|
||||
t.Fatalf("Milestones: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d milestones, want 2 (archived repo excluded): %+v", len(got), got)
|
||||
}
|
||||
byRepo := map[string]Milestone{}
|
||||
for _, m := range got {
|
||||
byRepo[m.Repo] = m
|
||||
}
|
||||
if m, ok := byRepo["api"]; !ok || m.Title != "v1" {
|
||||
t.Fatalf("missing api/v1; got %+v", got)
|
||||
}
|
||||
if m, ok := byRepo["web"]; !ok || m.Title != "launch" {
|
||||
t.Fatalf("missing web/launch; got %+v", got)
|
||||
}
|
||||
for _, m := range got {
|
||||
if m.Repo == "" {
|
||||
t.Fatalf("milestone %q has no repo stamped: an org rollup that cannot say where a milestone came from is unusable", m.Title)
|
||||
}
|
||||
if m.Title == "ancient" {
|
||||
t.Fatal("archived repo's milestone leaked into the rollup")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A partial rollup presented as a complete one is a wrong answer. One repo
|
||||
// failing must fail the whole call.
|
||||
func TestMilestones_OneRepoFailingFailsTheRollup(t *testing.T) {
|
||||
s := newStub(t)
|
||||
s.visible["alice"] = []string{"acme"}
|
||||
s.repos["acme"] = []Repo{{Name: "api"}, {Name: "web"}}
|
||||
s.milestones["acme/api"] = []Milestone{{Title: "v1"}}
|
||||
// acme/web has no milestones entry; the stub 404s only on an org the actor
|
||||
// cannot see, so make the failure explicit by swapping the handler.
|
||||
s.Server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.got.add(r)
|
||||
if strings.Contains(r.URL.Path, "/web/milestones") {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/repos") {
|
||||
writeJSON(w, s.repos["acme"])
|
||||
return
|
||||
}
|
||||
writeJSON(w, s.milestones["acme/api"])
|
||||
})
|
||||
|
||||
if _, err := s.client(t).As("alice").Milestones(t.Context(), "acme"); err == nil {
|
||||
t.Fatal("rollup succeeded while a repo failed; a partial answer must not read as complete")
|
||||
}
|
||||
}
|
||||
|
||||
// The fan-out must stay bounded, or a large org turns a board load into a
|
||||
// denial-of-service against our own forge.
|
||||
func TestMilestones_FanOutIsBounded(t *testing.T) {
|
||||
s := newStub(t)
|
||||
s.visible["alice"] = []string{"acme"}
|
||||
for i := range 40 {
|
||||
s.repos["acme"] = append(s.repos["acme"], Repo{Name: fmt.Sprintf("r%d", i)})
|
||||
}
|
||||
|
||||
var live, peak int64
|
||||
s.Server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/milestones") {
|
||||
n := atomic.AddInt64(&live, 1)
|
||||
for {
|
||||
old := atomic.LoadInt64(&peak)
|
||||
if n <= old || atomic.CompareAndSwapInt64(&peak, old, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
defer atomic.AddInt64(&live, -1)
|
||||
writeJSON(w, []Milestone{{Title: "m"}})
|
||||
return
|
||||
}
|
||||
writeJSON(w, pageOf(s.repos["acme"], r))
|
||||
})
|
||||
|
||||
if _, err := s.client(t).As("alice").Milestones(t.Context(), "acme"); err != nil {
|
||||
t.Fatalf("Milestones: %v", err)
|
||||
}
|
||||
if p := atomic.LoadInt64(&peak); p > fanout {
|
||||
t.Fatalf("peak concurrent repo requests = %d, want <= %d", p, fanout)
|
||||
}
|
||||
}
|
||||
|
||||
// ── pagination ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestIssues_PaginatesAndTerminates(t *testing.T) {
|
||||
s := newStub(t)
|
||||
s.visible["alice"] = []string{"acme"}
|
||||
for i := range 120 {
|
||||
s.issues["acme"] = append(s.issues["acme"], Issue{Number: int64(i + 1)})
|
||||
}
|
||||
got, err := s.client(t).As("alice").Issues(t.Context(), "acme", IssueFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Issues: %v", err)
|
||||
}
|
||||
if len(got) != 120 {
|
||||
t.Fatalf("got %d issues, want 120", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssues_LimitIsHonoured(t *testing.T) {
|
||||
s := newStub(t)
|
||||
s.visible["alice"] = []string{"acme"}
|
||||
for i := range 120 {
|
||||
s.issues["acme"] = append(s.issues["acme"], Issue{Number: int64(i + 1)})
|
||||
}
|
||||
got, err := s.client(t).As("alice").Issues(t.Context(), "acme", IssueFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("Issues: %v", err)
|
||||
}
|
||||
if len(got) != 10 {
|
||||
t.Fatalf("got %d issues, want 10", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// A forge that never stops answering full pages must not spin this process
|
||||
// forever.
|
||||
func TestIssues_StopsAtMaxPagesAgainstAnEndlessForge(t *testing.T) {
|
||||
s := newStub(t)
|
||||
var hits int64
|
||||
s.Server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&hits, 1)
|
||||
full := make([]Issue, page)
|
||||
writeJSON(w, full)
|
||||
})
|
||||
got, err := s.client(t).As("alice").Issues(t.Context(), "acme", IssueFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Issues: %v", err)
|
||||
}
|
||||
if n := atomic.LoadInt64(&hits); n > maxPages {
|
||||
t.Fatalf("made %d requests against an endless forge, want <= %d", n, maxPages)
|
||||
}
|
||||
if len(got) != page*maxPages {
|
||||
t.Fatalf("got %d issues, want the bounded %d", len(got), page*maxPages)
|
||||
}
|
||||
}
|
||||
|
||||
// ── As() must not mutate a shared client ─────────────────────────────────────
|
||||
|
||||
// Two concurrent requests derive two actors from one shared client. If As
|
||||
// mutated the receiver they would interleave and one user's request would be
|
||||
// attributed to the other — a cross-user attribution race.
|
||||
func TestAs_DoesNotMutateTheSharedClient(t *testing.T) {
|
||||
s := newStub(t)
|
||||
s.visible["alice"] = []string{"acme"}
|
||||
s.visible["bob"] = []string{"acme"}
|
||||
shared := s.client(t)
|
||||
|
||||
if shared.Actor() != "" {
|
||||
t.Fatalf("fresh client already has actor %q", shared.Actor())
|
||||
}
|
||||
a, b := shared.As("alice"), shared.As("bob")
|
||||
if a.Actor() != "alice" || b.Actor() != "bob" {
|
||||
t.Fatalf("actors crossed: a=%q b=%q", a.Actor(), b.Actor())
|
||||
}
|
||||
if shared.Actor() != "" {
|
||||
t.Fatalf("As mutated the shared client: actor is now %q", shared.Actor())
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 50 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if got := shared.As("alice").Actor(); got != "alice" {
|
||||
t.Errorf("concurrent As returned actor %q", got)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ── writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// A write with no actor must never fall back to the machine identity: that would
|
||||
// file issues as a shared bot and destroy attribution, on top of the escalation.
|
||||
func TestWrites_RefuseWithoutAnActor(t *testing.T) {
|
||||
s := newStub(t)
|
||||
c := s.client(t)
|
||||
title := "x"
|
||||
calls := map[string]func() error{
|
||||
"CreateIssue": func() error { _, err := c.CreateIssue(t.Context(), "acme", "api", NewIssue{Title: "t"}); return err },
|
||||
"PatchIssue": func() error { return c.PatchIssue(t.Context(), "acme", "api", 1, IssuePatch{Title: &title}) },
|
||||
"SetLabels": func() error { return c.SetLabels(t.Context(), "acme", "api", 1, []string{"todo"}) },
|
||||
}
|
||||
for name, call := range calls {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := call(); !errors.Is(err, ErrNoActor) {
|
||||
t.Fatalf("%s unscoped: err = %v, want ErrNoActor", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if n := len(s.got.all()); n != 0 {
|
||||
t.Fatalf("%d write requests reached the forge unscoped; want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Every write must be attributed to the human via Sudo, so the forge records who
|
||||
// really did it.
|
||||
func TestWrites_AreAttributedToTheActor(t *testing.T) {
|
||||
s := newStub(t)
|
||||
var got []*http.Request
|
||||
var mu sync.Mutex
|
||||
s.Server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
got = append(got, r.Clone(context.Background()))
|
||||
mu.Unlock()
|
||||
writeJSON(w, Issue{Number: 5, Title: "filed"})
|
||||
})
|
||||
c := s.client(t).As("alice")
|
||||
|
||||
if _, err := c.CreateIssue(t.Context(), "acme", "api", NewIssue{Title: "filed", Labels: []string{"todo"}}); err != nil {
|
||||
t.Fatalf("CreateIssue: %v", err)
|
||||
}
|
||||
if err := c.SetLabels(t.Context(), "acme", "api", 5, []string{"in_progress"}); err != nil {
|
||||
t.Fatalf("SetLabels: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d requests, want 2", len(got))
|
||||
}
|
||||
for _, r := range got {
|
||||
if r.Header.Get("Sudo") != "alice" {
|
||||
t.Fatalf("%s %s carried Sudo=%q, want alice", r.Method, r.URL.Path, r.Header.Get("Sudo"))
|
||||
}
|
||||
if r.Header.Get("Authorization") != "token "+s.token {
|
||||
t.Fatalf("%s %s did not carry the machine credential", r.Method, r.URL.Path)
|
||||
}
|
||||
}
|
||||
if got[0].Method != http.MethodPost {
|
||||
t.Fatalf("CreateIssue used %s, want POST", got[0].Method)
|
||||
}
|
||||
// A move is a RELABEL — PUT on the labels sub-resource, replacing the set.
|
||||
if got[1].Method != http.MethodPut || !strings.HasSuffix(got[1].URL.Path, "/issues/5/labels") {
|
||||
t.Fatalf("SetLabels sent %s %s, want PUT .../issues/5/labels", got[1].Method, got[1].URL.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// The forge refusing a sudoed write (the ACTOR lacks the permission) must surface
|
||||
// as a refusal, not be swallowed into a success.
|
||||
func TestWrites_ForgeRefusalSurfaces(t *testing.T) {
|
||||
s := newStub(t)
|
||||
s.Server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
})
|
||||
err := s.client(t).As("alice").SetLabels(t.Context(), "acme", "api", 1, []string{"done"})
|
||||
if err == nil {
|
||||
t.Fatal("a 403 from the forge was swallowed; a refused write must not read as success")
|
||||
}
|
||||
if strings.Contains(err.Error(), s.token) {
|
||||
t.Fatalf("refusal leaked the credential: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A repo name is interpolated into the path exactly like an org, so it needs the
|
||||
// same refusal.
|
||||
func TestWrites_RefuseARepoThatIsNotAPathSegment(t *testing.T) {
|
||||
s := newStub(t)
|
||||
c := s.client(t).As("alice")
|
||||
for _, bad := range []string{"", "../../admin", "a/b", "x?y"} {
|
||||
if err := c.SetLabels(t.Context(), "acme", bad, 1, []string{"done"}); err == nil {
|
||||
t.Fatalf("SetLabels(repo=%q) succeeded; want refusal", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An IssuePatch must omit absent fields, or a patch that only moves a card would
|
||||
// blank the title and body.
|
||||
func TestIssuePatch_OmitsAbsentFields(t *testing.T) {
|
||||
s := newStub(t)
|
||||
var body []byte
|
||||
s.Server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ = io.ReadAll(r.Body)
|
||||
writeJSON(w, Issue{})
|
||||
})
|
||||
state := "closed"
|
||||
if err := s.client(t).As("alice").PatchIssue(t.Context(), "acme", "api", 3, IssuePatch{State: &state}); err != nil {
|
||||
t.Fatalf("PatchIssue: %v", err)
|
||||
}
|
||||
var sent map[string]any
|
||||
if err := json.Unmarshal(body, &sent); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if _, ok := sent["title"]; ok {
|
||||
t.Fatalf("patch sent a title it was not given: %s", body)
|
||||
}
|
||||
if _, ok := sent["body"]; ok {
|
||||
t.Fatalf("patch sent a body it was not given: %s", body)
|
||||
}
|
||||
if sent["state"] != "closed" {
|
||||
t.Fatalf("patch did not carry the state: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package forge_test
|
||||
|
||||
// live_test.go exercises this client against a REAL forge.
|
||||
//
|
||||
// It is SKIPPED unless FORGE_LIVE_TOKEN and FORGE_LIVE_ORG are set, so it never
|
||||
// runs in CI and never needs a credential to be available there. It exists
|
||||
// because the stub in forge_test.go encodes behaviours — Sudo drops privilege,
|
||||
// an unknown actor is 404, issues-search answers labels and milestones inline —
|
||||
// that are ASSERTIONS ABOUT AN UPSTREAM WE DO NOT CONTROL. A stub can only ever
|
||||
// confirm we implemented what we believed; this confirms what we believed is
|
||||
// true, and it is the thing to re-run when Forgejo is upgraded.
|
||||
//
|
||||
// FORGE_LIVE_TOKEN=… FORGE_LIVE_ORG=hanzoai FORGE_LIVE_ACTOR=z \
|
||||
// go test -run Live -v ./forge/
|
||||
//
|
||||
// The token is read from the ENVIRONMENT and never from a file in the repo. In
|
||||
// production the same credential comes from KMS (apps/tracker/source.go).
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/forge"
|
||||
)
|
||||
|
||||
func liveClient(t *testing.T) (*forge.Client, string) {
|
||||
t.Helper()
|
||||
token := strings.TrimSpace(os.Getenv("FORGE_LIVE_TOKEN"))
|
||||
org := strings.TrimSpace(os.Getenv("FORGE_LIVE_ORG"))
|
||||
if token == "" || org == "" {
|
||||
t.Skip("set FORGE_LIVE_TOKEN and FORGE_LIVE_ORG to run the live forge checks")
|
||||
}
|
||||
host := strings.TrimSpace(os.Getenv("FORGE_LIVE_HOST"))
|
||||
if host == "" {
|
||||
host = "git.hanzo.ai"
|
||||
}
|
||||
actor := strings.TrimSpace(os.Getenv("FORGE_LIVE_ACTOR"))
|
||||
if actor == "" {
|
||||
t.Skip("set FORGE_LIVE_ACTOR to the forge login to act as")
|
||||
}
|
||||
c, err := forge.New(host, token)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return c.As(actor), org
|
||||
}
|
||||
|
||||
// The org rollup against a real forge: list the org's repos, fan out over their
|
||||
// milestones, and prove every row names the repo it came from.
|
||||
func TestLive_MilestoneOrgRollup(t *testing.T) {
|
||||
c, org := liveClient(t)
|
||||
|
||||
repos, err := c.Repos(t.Context(), org)
|
||||
if err != nil {
|
||||
t.Fatalf("Repos(%s): %v", org, err)
|
||||
}
|
||||
if len(repos) == 0 {
|
||||
t.Fatalf("%s has no repositories visible to this actor", org)
|
||||
}
|
||||
t.Logf("live: %s has %d repositories visible", org, len(repos))
|
||||
|
||||
ms, err := c.Milestones(t.Context(), org)
|
||||
if err != nil {
|
||||
t.Fatalf("Milestones(%s): %v", org, err)
|
||||
}
|
||||
t.Logf("live: org rollup returned %d milestones across %d repos", len(ms), len(repos))
|
||||
for _, m := range ms {
|
||||
if m.Repo == "" {
|
||||
t.Errorf("milestone %q (id %d) does not name its repo", m.Title, m.ID)
|
||||
}
|
||||
t.Logf(" %s/%s: %q state=%s open=%d closed=%d", org, m.Repo, m.Title, m.State, m.Open, m.Closed)
|
||||
}
|
||||
}
|
||||
|
||||
// The issues search really does answer labels, milestone and the owning
|
||||
// repository inline — which is what lets a board render from one request.
|
||||
func TestLive_IssuesSearchCarriesTheBoardInline(t *testing.T) {
|
||||
c, org := liveClient(t)
|
||||
|
||||
issues, err := c.Issues(t.Context(), org, forge.IssueFilter{State: "all", Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("Issues(%s): %v", org, err)
|
||||
}
|
||||
t.Logf("live: %d issues returned for %s", len(issues), org)
|
||||
if len(issues) == 0 {
|
||||
t.Skip("no issues on this org to inspect")
|
||||
}
|
||||
withRepo := 0
|
||||
for _, is := range issues {
|
||||
if is.Repository != nil && is.Repository.Name != "" {
|
||||
withRepo++
|
||||
}
|
||||
}
|
||||
if withRepo != len(issues) {
|
||||
t.Errorf("%d/%d issues named their repository; the board cannot address the rest",
|
||||
withRepo, len(issues))
|
||||
}
|
||||
is := issues[0]
|
||||
t.Logf(" sample: %s#%d %q state=%s labels=%d assignees=%d",
|
||||
func() string {
|
||||
if is.Repository != nil {
|
||||
return is.Repository.Name
|
||||
}
|
||||
return "?"
|
||||
}(), is.Number, is.Title, is.State, len(is.Labels), len(is.Assignees))
|
||||
}
|
||||
|
||||
// THE SAFETY PROPERTY, against the real forge: Sudo DROPS PRIVILEGE. Reading a
|
||||
// repository the actor cannot see must fail even though the machine token can
|
||||
// see it. Requires FORGE_LIVE_PRIVATE (an "org/repo" the actor may NOT read).
|
||||
func TestLive_SudoDropsPrivilege(t *testing.T) {
|
||||
c, _ := liveClient(t)
|
||||
target := strings.TrimSpace(os.Getenv("FORGE_LIVE_PRIVATE"))
|
||||
if target == "" {
|
||||
t.Skip("set FORGE_LIVE_PRIVATE=org/repo (one the actor may NOT read) to check privilege drop")
|
||||
}
|
||||
parts := strings.SplitN(target, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("FORGE_LIVE_PRIVATE = %q, want org/repo", target)
|
||||
}
|
||||
ms, err := c.Milestones(t.Context(), parts[0])
|
||||
if err == nil && len(ms) > 0 {
|
||||
t.Fatalf("the sudoed actor read %d milestones from %s — Sudo did not drop privilege, "+
|
||||
"and the machine credential is therefore the only thing standing between "+
|
||||
"one tenant and another", len(ms), parts[0])
|
||||
}
|
||||
t.Logf("live: sudoed actor correctly cannot read %s (err=%v)", target, err)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package plane
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The two ops that file work items share ONE socket, and they must not disagree
|
||||
// about where tenancy comes from.
|
||||
//
|
||||
// AgentPRIn used to carry an Org field which plugin/tracker/seams.go read off
|
||||
// the wire and passed straight into the per-tenant store selector
|
||||
// (apps/tracker/agentpr.go storeFor), so a caller on the plane could file a work
|
||||
// item onto ANOTHER tenant's board simply by naming it. Its sibling IssueIn has
|
||||
// never had one, and says why in its own doc comment.
|
||||
//
|
||||
// This test is the gate on that: re-adding an org-shaped field to either input
|
||||
// re-opens the hole, and it must break a named test rather than pass review as a
|
||||
// convenience.
|
||||
func TestWorkItemInputsCarryNoTenancy(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
typ reflect.Type
|
||||
}{
|
||||
{"AgentPRIn", reflect.TypeFor[AgentPRIn]()},
|
||||
{"IssueIn", reflect.TypeFor[IssueIn]()},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for i := range tc.typ.NumField() {
|
||||
f := tc.typ.Field(i)
|
||||
if isTenancy(f.Name) {
|
||||
t.Fatalf("%s has a %s field: the org is the CALLER's plane identity "+
|
||||
"(cloud.Who), never an argument — a caller able to state the tenant "+
|
||||
"can write into another tenant's tracker",
|
||||
tc.name, f.Name)
|
||||
}
|
||||
if tag := f.Tag.Get("json"); isTenancy(strings.Split(tag, ",")[0]) {
|
||||
t.Fatalf("%s field %s serialises as %q, which is a tenant key on the wire",
|
||||
tc.name, f.Name, tag)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isTenancy names the field spellings that would put a tenant key on the wire.
|
||||
// Project is NOT one of them: it is the IAM sub-scope WITHIN the caller's org,
|
||||
// and it cannot cross an org boundary on its own.
|
||||
func isTenancy(name string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "org", "orgid", "owner", "tenant", "tenantid", "account", "accountid":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
+7
-1
@@ -2092,8 +2092,14 @@ type RefTip struct {
|
||||
}
|
||||
|
||||
// AgentPRIn opens the native PR work item for a pushed branch.
|
||||
//
|
||||
// IT CARRIES NO ORG, deliberately, and for the same reason [IssueIn] carries
|
||||
// none: the org is the CALLER's plane identity (cloud.Who), and a field here
|
||||
// would let a caller state the tenant it is filing into — which is a
|
||||
// cross-tenant WRITE the caller asserted for itself. Two ops share this socket
|
||||
// and they must not disagree about where tenancy comes from; the one that reads
|
||||
// it off the wire is the one that is wrong.
|
||||
type AgentPRIn struct {
|
||||
Org string `json:"org"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Repo string `json:"repo"`
|
||||
Base string `json:"base,omitempty"`
|
||||
|
||||
+13
-1
@@ -30,9 +30,21 @@ func init() {
|
||||
// an ERROR and never an empty handle: the caller records a PR-less run as a
|
||||
// recorded problem, and an empty identifier that arrived as success would show a
|
||||
// Slack card claiming a PR nobody can open.
|
||||
//
|
||||
// The org is the CALLER's plane identity and never the argument, exactly as
|
||||
// planeUpsert resolves it on this same socket (apps/tracker/upsert_plane.go).
|
||||
// It used to be in.Org — read off the wire and passed straight into the
|
||||
// per-tenant store selector — so a caller on the plane could file a work item
|
||||
// onto ANOTHER tenant's board by naming it. Anonymous is refused rather than
|
||||
// defaulted: a run arriving with no principal must fail, not land on somebody's
|
||||
// board.
|
||||
func planeAgentPR(ctx context.Context, in *plane.AgentPRIn) (*plane.AgentPROut, error) {
|
||||
who := cloud.Who(ctx)
|
||||
if who.Org == "" {
|
||||
return nil, zip.ErrForbidden("tracker agent-pr: org required")
|
||||
}
|
||||
pr, err := tracker.CreateAgentPR(ctx, tracker.AgentPRInput{
|
||||
Org: in.Org, Project: in.Project, Repo: in.Repo, Base: in.Base,
|
||||
Org: who.Org, Project: in.Project, Repo: in.Repo, Base: in.Base,
|
||||
Head: in.Head, Title: in.Title, Body: in.Body, Assignee: in.Assignee,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -144,6 +144,13 @@ var allowedRequestUses = map[string]string{
|
||||
"request. Fails closed off the HTTP path: no request means the unbilled, default-project answer, " +
|
||||
"and tenant() has already refused before any op reaches it.",
|
||||
"apps/search/search.go": "Query resolves the tenant from the validated principal at the top of the op.",
|
||||
"apps/tracker/source.go": "scopeForge. The forge-backed board needs the caller's IAM USERNAME " +
|
||||
"(X-User-Name) as well as their org: the org says WHICH tenant's work to ask the forge for, and the " +
|
||||
"username is who the forge is asked AS (Forgejo Sudo), which drops privilege to that user so the " +
|
||||
"forge's own ACL re-checks the answer. principal.OrgFrom carries the org and nothing else, so an op " +
|
||||
"without the request could not name an actor — and an actorless forge call would fall back to the " +
|
||||
"deployment's machine credential, reading every repository that token can see. Fails closed off the " +
|
||||
"HTTP path with the same 403.",
|
||||
"apps/tracker/typed.go": "scope / requireBody. scope needs the IAM PROJECT (X-Project-Id), which " +
|
||||
"picks the physical per-(org,project) store a tracker read opens — principal.OrgFrom carries the org " +
|
||||
"and nothing else, so an op without it would open a different file than the create wrote. requireBody " +
|
||||
|
||||
Reference in New Issue
Block a user