Files
zeekay 6b39afc596 One machines collection, and pools read the address they are served on
There were two machine collections. One read a table visor rebuilds from the
organization's own provider credentials; the other read the cloud API on the
house account. They were never two kinds of thing: object.Machine and
service.Machine both key on (Owner, Name) and both carry Id as the provider's
id, so only the SOURCE differed — which is a property of a machine, not a
reason for a second address. A caller wanting "the machines I have" had to ask
both and join them, and one that asked once got a partial answer without being
told. GET /v1/machines now answers from both, tagged by source, registry
winning a collision.

The item is at /v1/machines/{owner}/{name}; adding to the collection is POST.
Six verb addresses and /v1/machines/launch retire to 410.

The node-pool family had the reverse problem: the routes moved to item
addresses in an earlier commit and the handlers kept reading the pool from the
body and the query, so delete and scale answered for whatever the body said.
Both now take the name from the address and the owner from the token, and scale
resolves the provider, cluster and upstream pool id from the row visor already
keeps — a caller that has to repeat them can contradict them.

AddMachine is deleted with the console button that called it: it inserted a row
no provider sync owns, and its address now means launch, which spends money.
GetMachines and LaunchMachine were unrouted.

Also here, because the tests found them:

- isResellComputePath keyed on /v1/machines/launch, so POST /v1/machines would
  have been denied for every authenticated user.
- A doc block for a handler that moved stayed behind and described the next
  function down, publishing UpdateMachine as GET /get-machine.
- The tenant and pool tests registered a shape routers.Route no longer serves,
  so they exercised the query fallback rather than the path the router uses.
  They now register what production registers and name the victim's org in the
  ADDRESS, which is the stronger form of the same attack.
- Two request helpers fixed the method, so an item table answered 404 and a 404
  reads as a refusal. One helper, method as a parameter.
- The launch tenant test proved its point through an error message that named
  an org; the handler now refuses earlier, for an unrelated reason. The subject
  is the resolver itself, which is the one place that decides, with a control
  covering both the query and the newer path vector.
- The unauthenticated control named an address that retires to 410, so it held
  no matter what the filter did.
- pkg/visor's bind raced its own TempDir: Close returns before the transport
  goroutine stops writing socket files. It now waits for the name to leave the
  plane.
2026-08-22 14:21:55 -07:00

99 lines
4.1 KiB
Go

// Copyright 2025 Hanzo Industries Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package authz
import (
"testing"
"github.com/hanzoai/iamsdk/v2/iamsdk"
)
// isResellComputePath decides which routes an authenticated brand user may reach
// (org-scoping is enforced downstream in the controller). These cases pin the
// exact resell-compute surface so a future edit can't silently widen it.
func TestIsResellComputePath(t *testing.T) {
allow := []struct{ method, path string }{
{"GET", "/v1/regions"},
{"GET", "/v1/sizes"},
{"GET", "/v1/gpus"},
{"GET", "/v1/machines"},
{"POST", "/v1/machines"},
{"GET", "/v1/machines/acme/abc-123"},
{"DELETE", "/v1/machines/acme/abc-123"},
// A machine's agent — one address, the method carrying the verb.
{"GET", "/v1/machines/agents"},
{"GET", "/v1/machines/acme/abc-123/agent"},
{"DELETE", "/v1/machines/acme/abc-123/agent"},
{"PUT", "/v1/machines/acme/abc-123/agent"},
}
for _, c := range allow {
if !isResellComputePath(c.method, c.path) {
t.Errorf("expected ALLOW for %s %s", c.method, c.path)
}
}
deny := []struct{ method, path string }{
{"POST", "/v1/regions"}, // catalog is read-only
{"DELETE", "/v1/machines"}, // the collection takes GET and POST, nothing else
{"GET", "/v1/plans"}, // not a resell-compute route
{"POST", "/v1/start-session"}, // legacy surface, gated separately
{"GET", "/v1/get-account"},
{"PUT", "/v1/machines/acme/abc-123"}, // only GET/DELETE on an item
{"POST", "/v1/machines/acme/abc-123"}, // no blanket POST on an item
{"POST", "/v1/machines/acme/abc-123/agent"}, // the bind is PUT, and only PUT
{"PUT", "/v1/machines/acme/abc-123/tag"}, // PUT is admitted for /agent alone
{"POST", "/v1/machines/agents"}, // the binding list is read-only
{"PUT", "/v1/machines/agents"}, // …and is not a bind target
{"GET", "/v1/agent-bindings"}, // the old list address is gone
{"POST", "/v1/machines/acme/abc-123/bind-agent"}, // the old bind address is gone
}
for _, c := range deny {
if isResellComputePath(c.method, c.path) {
t.Errorf("expected DENY for %s %s", c.method, c.path)
}
}
}
// The object's owner is not a statement about the subject. IsAllowed used to
// return true for ANY authenticated user whenever the OBJECT's owner was "admin"
// — the reserved SuperAdmin org — so every customer of the brand was admitted to
// every SuperAdmin-owned object on every route the static policy did not cover.
//
// A member of the admin org still passes, because subOwner == objOwner is what
// membership means. That is the whole rule; there is no second clause.
func TestAdminOwnedObjectIsNotEveryonesObject(t *testing.T) {
InitAuthz()
customer := &iamsdk.User{Owner: "acme", Name: "alice"}
superadmin := &iamsdk.User{Owner: "admin", Name: "z"}
// A route the static policy does not cover, so the decision is the subject
// rule alone.
const method, path = "POST", "/v1/pools"
if IsAllowed(customer, "acme", "alice", method, path, "admin", "thing") {
t.Fatal("a customer must not reach a SuperAdmin-owned object")
}
if !IsAllowed(superadmin, "admin", "z", method, path, "admin", "thing") {
t.Fatal("a member of the admin org must still reach its own org's objects")
}
if !IsAllowed(customer, "acme", "alice", method, path, "acme", "thing") {
t.Fatal("a customer must still reach its OWN org's objects")
}
if IsAllowed(customer, "acme", "alice", method, path, "victim", "thing") {
t.Fatal("a customer must not reach another customer's objects")
}
}