Files

281 lines
9.8 KiB
Go

// Copyright 2026 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 routers
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"math/big"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/hanzoai/iamsdk/v2/iamsdk"
"github.com/zap-proto/zip"
"github.com/hanzoai/compute/controllers"
"github.com/hanzoai/compute/object"
)
// ownedKind is one org-owned collection: its address, how to seed a row, and how
// to read one back as JSON ("" when absent).
type ownedKind struct {
kind string
seed func(owner, name string) error
read func(owner, name string) (string, error)
add func(*controllers.ApiController)
update func(*controllers.ApiController)
remove func(*controllers.ApiController)
}
func present(v any, err error) (string, error) {
if err != nil || v == nil || reflect.ValueOf(v).IsNil() {
return "", err
}
b, err := json.Marshal(v)
return string(b), err
}
func ownedKinds() []ownedKind {
return []ownedKind{
{
kind: "providers",
seed: func(o, n string) error {
_, err := object.AddProvider(&object.Provider{Owner: o, Name: n, Category: "Public Cloud", Type: "Amazon Web Services", DisplayName: "seed"})
return err
},
read: func(o, n string) (string, error) { return present(object.GetProvider(o + "/" + n)) },
add: (*controllers.ApiController).AddProvider,
update: (*controllers.ApiController).UpdateProvider,
remove: (*controllers.ApiController).DeleteProvider,
},
{
kind: "assets",
seed: func(o, n string) error {
_, err := object.AddAsset(&object.Asset{Owner: o, Name: n, DisplayName: "seed"})
return err
},
read: func(o, n string) (string, error) { return present(object.GetAsset(o + "/" + n)) },
add: (*controllers.ApiController).AddAsset,
update: (*controllers.ApiController).UpdateAsset,
remove: (*controllers.ApiController).DeleteAsset,
},
{
kind: "records",
seed: func(o, n string) error {
object.AddRecord(&object.Record{Organization: o, Name: n, Method: "POST", Action: "seed"})
return nil
},
read: func(o, n string) (string, error) { return present(object.GetRecord(o + "/" + n)) },
add: (*controllers.ApiController).AddRecord,
update: (*controllers.ApiController).UpdateRecord,
remove: (*controllers.ApiController).DeleteRecord,
},
{
kind: "sessions",
seed: func(o, n string) error {
_, err := object.AddSession(&object.Session{Owner: o, Name: n, Protocol: "ssh"})
return err
},
read: func(o, n string) (string, error) { return present(object.GetConnSession(o + "/" + n)) },
add: (*controllers.ApiController).AddSession,
update: (*controllers.ApiController).UpdateSession,
remove: (*controllers.ApiController).DeleteSession,
},
{
kind: "plans",
seed: func(o, n string) error {
_, err := object.AddPlan(&object.Plan{Owner: o, Name: n, DisplayName: "seed"})
return err
},
read: func(o, n string) (string, error) { return present(object.GetPlan(o, n)) },
add: (*controllers.ApiController).AddPlan,
update: (*controllers.ApiController).UpdatePlan,
remove: (*controllers.ApiController).DeletePlan,
},
}
}
// A write lands on the caller's own org and the address's name, whatever the body
// says: a tenant cannot create, change or delete a row of the admin org, or of any
// org other than its own, through any org-owned collection, while a member of the
// admin org writes admin rows.
func TestAWriteLandsOnTheCallersOwnRow(t *testing.T) {
redStore.Do(func() {
root, _ := os.MkdirTemp("", "red3")
_ = os.Setenv("dataRoot", root)
object.InitAdapter()
})
for _, k := range ownedKinds() {
t.Run(k.kind, func(t *testing.T) {
app := zip.New(zip.Config{ReadBufferSize: 16384})
app.Use(zip.H(ApiFilter))
app.Post("/v1/"+k.kind, h(k.add))
app.Put("/v1/"+k.kind+"/:owner/:name", h(k.update))
app.Delete("/v1/"+k.kind+"/:owner/:name", h(k.remove))
send := func(org, method, path, body string) {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Authorization", redSign(t, org))
req.Header.Set("Content-Type", "application/json")
if _, err := app.Test(req); err != nil {
t.Fatal(err)
}
}
row := func(owner, name string) string {
t.Helper()
got, err := k.read(owner, name)
if err != nil {
t.Fatal(err)
}
return got
}
if err := k.seed("admin", "seed"); err != nil {
t.Fatal(err)
}
if err := k.seed("mallory", "seed"); err != nil {
t.Fatal(err)
}
before := row("admin", "seed")
if before == "" {
t.Fatal("the admin row was not seeded")
}
send("mallory", "POST", "/v1/"+k.kind+"?id=mallory/evil", `{"owner":"admin","name":"evil"}`)
if row("admin", "evil") != "" {
t.Errorf("a tenant created admin/evil")
}
if row("mallory", "evil") != "" {
t.Errorf("a body naming admin was written to mallory/evil instead of refused")
}
send("mallory", "PUT", "/v1/"+k.kind+"/mallory/seed", `{"owner":"admin","name":"seed","displayName":"pwned","protocol":"pwned","action":"pwned"}`)
if after := row("admin", "seed"); after != before {
t.Errorf("a tenant changed admin/seed:\n%s\n%s", before, after)
}
send("mallory", "PUT", "/v1/"+k.kind+"/mallory/seed", `{"owner":"mallory","name":"renamed"}`)
if row("mallory", "renamed") != "" || row("mallory", "seed") == "" {
t.Errorf("a body's name moved mallory/seed")
}
if err := k.seed("mallory", "keep"); err != nil {
t.Fatal(err)
}
send("mallory", "DELETE", "/v1/"+k.kind+"/mallory/anything", `{"owner":"mallory","name":"keep"}`)
if row("mallory", "keep") == "" {
t.Errorf("a delete of mallory/anything deleted the body's mallory/keep")
}
send("mallory", "DELETE", "/v1/"+k.kind+"/mallory/anything", `{"owner":"admin","name":"seed"}`)
send("mallory", "DELETE", "/v1/"+k.kind+"/mallory/seed", ``)
if row("admin", "seed") == "" {
t.Errorf("a tenant deleted admin/seed")
}
if row("mallory", "seed") != "" {
t.Errorf("the tenant's own delete of mallory/seed did not land")
}
send("mallory", "POST", "/v1/"+k.kind, `{"owner":"mallory","name":"mine"}`)
if row("mallory", "mine") == "" {
t.Errorf("the tenant's own create of mallory/mine did not land")
}
send("admin", "POST", "/v1/"+k.kind, `{"owner":"admin","name":"platform"}`)
if row("admin", "platform") == "" {
t.Errorf("a member of admin could not create admin/platform")
}
// A member of two orgs creates in the second by naming it.
req := httptest.NewRequest("POST", "/v1/"+k.kind, strings.NewReader(`{"owner":"beta","name":"second"}`))
req.Header.Set("Authorization", signMember(t, "acme", "beta"))
req.Header.Set("X-Org-Id", "beta")
req.Header.Set("Content-Type", "application/json")
if _, err := app.Test(req); err != nil {
t.Fatal(err)
}
if row("beta", "second") == "" {
t.Errorf("a member of acme and beta could not create beta/second")
}
})
}
}
// The service caller authenticates by Basic with this service's own client id
// and secret. The same pair in the query string, or the secret under another
// client id, is no subject at all.
func TestTheServiceCallerIsBasicWithItsOwnId(t *testing.T) {
t.Setenv("clientId", "hanzo-visor")
t.Setenv("clientSecret", "s3cret")
t.Setenv("iamApplication", "visor")
app := zip.New(zip.Config{})
var got string
app.Get("/v1/x", func(c *zip.Ctx) error {
got, _ = getUsernameByClientIdSecret(c)
return nil
})
for _, tc := range []struct {
name, query, basic, want string
}{
{"Basic with its own pair", "", "hanzo-visor:s3cret", "app/visor"},
{"the pair in the query", "?clientId=hanzo-visor&clientSecret=s3cret", "", ""},
{"the secret under another id", "", "other-app:s3cret", ""},
{"a wrong secret", "", "hanzo-visor:guess", ""},
} {
req := httptest.NewRequest("GET", "/v1/x"+tc.query, nil)
if tc.basic != "" {
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(tc.basic)))
}
got = "unset"
if _, err := app.Test(req); err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("%s: subject %q, want %q", tc.name, got, tc.want)
}
}
}
// signMember is a bearer for a person who is a member of orgs, the first being
// their home org, signed by a key the IAM configuration is set to trust.
func signMember(t *testing.T, orgs ...string) string {
t.Helper()
key, _ := rsa.GenerateKey(rand.Reader, 2048)
tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "t"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
iamsdk.InitConfig("", "", "", string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), "", "")
t.Setenv("iamIssuer", "https://test.id")
t.Setenv("iamAudience", "hanzo-visor")
c := &iamsdk.Claims{}
c.Owner, c.Name = orgs[0], "member"
c.Issuer = "https://test.id"
c.Audience = []string{"hanzo-visor"}
c.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Hour))
for _, o := range orgs {
c.Orgs = append(c.Orgs, iamsdk.OrgRef{Org: o, Role: "member"})
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, c).SignedString(key)
if err != nil {
t.Fatal(err)
}
return "Bearer " + signed
}