Files
base/plugins/org/org_routes_test.go
zeekay 4506925bff Mirror a superuser only for the admin org's own people
hanzoai/authz v1.10.42 decides Sudo on the person's own org, the first
entry of orgs, so a brand org's user holding an admin-org membership no
longer mirrors onto _superusers, and that membership no longer selects
another tenant's Base or the admin org's.

The fixtures that stood for a SuperAdmin now carry admin first, and
TestIAMSuperuserMirror, TestOperatorMayReachATenant and
TestOperatorReachesTheOrgItNames each refuse the brand member beside
the operator they admit.

Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
2026-09-27 19:34:54 -07:00

911 lines
36 KiB
Go

package org
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tests"
"github.com/hanzoai/base/tools/logger"
)
// twoOrgs stands up one process serving alpha and beta, the way a deployment
// does, and hands back an issuer to mint tokens with.
func twoOrgs(t *testing.T) (core.App, *issuer, http.Handler, *OrgDB) {
t.Helper()
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
t.Cleanup(app.Cleanup)
iam := newIssuer(t)
// A KMS that stores nothing and answers at once. The credential routes reach
// KMS on the path where they are ALLOWED to, and find nothing there.
if err := Register(app, Config{IAMEndpoint: iam.url, KMSEndpoint: kmsHolding(t, ""),
IAMClientID: "svc", IAMClientSecret: "shh", IAMOrg: "hanzo"}); err != nil {
t.Fatal(err)
}
db := NewOrgDB(app, "")
for _, org := range []string{"alpha", "beta"} {
if _, err := db.ProvisionOrg(org); err != nil {
t.Fatal(err)
}
}
return app, iam, serve(t, app), db
}
func call(t *testing.T, mux http.Handler, method, path, token string, headers map[string]string) (int, string) {
t.Helper()
r := httptest.NewRequest(method, path, strings.NewReader("{}"))
r.Header.Set("Content-Type", "application/json")
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range headers {
r.Header.Set(k, v)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, r)
return rec.Code, rec.Body.String()
}
// orgRoutes is every route that names an org, which is every route the rule
// governs. Adding one without adding it here is the mistake the table exists to
// catch: three of these seven shipped with no check at all.
var orgRoutes = []struct{ method, path string }{
{http.MethodGet, "/v1/bases/%s"},
{http.MethodGet, "/v1/bases/%s/config"},
{http.MethodGet, "/v1/bases/%s/creds/stripe"},
{http.MethodPost, "/v1/bases/%s/creds/stripe"},
{http.MethodDelete, "/v1/bases/%s/creds"},
{http.MethodGet, "/v1/bases/%s/customers/beta%2Fbob"},
{http.MethodPost, "/v1/bases/%s/customers/beta%2Fbob"},
}
// TestNamingAnotherOrgIsRefusedOnEveryRoute pins the rule for the whole
// subtree: the org a request names is the org it acts in.
//
// The refusal must be 403 and not 404. A 404 is an answer about the data, so a
// caller who reads one learns the check passed and the org was simply empty —
// which is what /v1/bases/{org}/config answered against production, and the
// only reason the read looked harmless is that no org has a config row yet.
func TestNamingAnotherOrgIsRefusedOnEveryRoute(t *testing.T) {
// A credential store beta could have, so a read that gets through has
// something to return.
t.Setenv("STRIPE_API_KEY", "beta-secret")
_, iam, mux, _ := twoOrgs(t)
alpha := iam.token(t, "alpha/ann", "alpha")
for _, r := range orgRoutes {
path := strings.Replace(r.path, "%s", "beta", 1)
code, body := call(t, mux, r.method, path, alpha, nil)
if code != http.StatusForbidden {
t.Errorf("%s %s as alpha answered %d %s, want 403", r.method, path, code, body)
}
if strings.Contains(body, "beta-secret") {
t.Errorf("%s %s handed alpha beta's credential: %s", r.method, path, body)
}
}
}
// TestActingInAnOrgReachesIt is the other half, and the half that makes the
// first one worth having: the same routes answer for the org the caller does
// act in. A rule that refuses everyone is not a rule, it is an outage.
func TestActingInAnOrgReachesIt(t *testing.T) {
_, iam, mux, _ := twoOrgs(t)
alpha := iam.token(t, "alpha/ann", "alpha")
for _, r := range orgRoutes {
path := strings.Replace(r.path, "%s", "alpha", 1)
if code, body := call(t, mux, r.method, path, alpha, nil); code == http.StatusForbidden {
t.Errorf("%s %s refused alpha its own org: %s", r.method, path, body)
}
}
}
// TestOperatorReachesTheOrgItNames pins the one cross-tenant scope in the
// estate. An operator is a person whose own org is the reserved admin org, says
// which org it means, and reaches it — through exactly the same rule as everyone
// else, since selecting an org is what EffectiveOrg grants a platform operator
// over any org and everyone else over their own.
func TestOperatorReachesTheOrgItNames(t *testing.T) {
_, iam, mux, _ := twoOrgs(t)
// Home is the admin org; that is what confers the scope.
op := iam.token(t, "admin/operator", "admin", "hanzo")
beta := map[string]string{"X-Org-Id": "beta"}
for _, r := range orgRoutes {
path := strings.Replace(r.path, "%s", "beta", 1)
if code, body := call(t, mux, r.method, path, op, beta); code == http.StatusForbidden {
t.Errorf("%s %s refused a platform operator acting as beta: %s", r.method, path, body)
}
}
// And naming one org while acting in another is refused for an operator
// too. The scope is over which org it may act in, not over the rule.
code, body := call(t, mux, http.MethodGet, "/v1/bases/alpha/config", op, beta)
if code != http.StatusForbidden {
t.Errorf("an operator acting as beta read alpha: %d %s", code, body)
}
// A brand org's person holding an admin-org membership is not an operator:
// the membership reaches no tenant.
member := iam.token(t, "hanzo/operator", "hanzo", "admin")
for _, r := range orgRoutes {
path := strings.Replace(r.path, "%s", "beta", 1)
if code, body := call(t, mux, r.method, path, member, beta); code != http.StatusForbidden {
t.Errorf("%s %s admitted an admin-org membership acting as beta: %d %s", r.method, path, code, body)
}
}
}
// TestAnUncredentialedCallerNamesNoOrg pins the failure direction. A request
// carrying nothing resolves no org, and no org matches every org rather than
// none unless the comparison says so.
func TestAnUncredentialedCallerNamesNoOrg(t *testing.T) {
_, _, mux, _ := twoOrgs(t)
for _, r := range orgRoutes {
path := strings.Replace(r.path, "%s", "alpha", 1)
if code, body := call(t, mux, r.method, path, "", nil); code != http.StatusForbidden {
t.Errorf("%s %s with no credential answered %d %s, want 403", r.method, path, code, body)
}
}
}
// TestAKeyActsInItsOwnOrg pins the other credential that opens these routes.
//
// A service fetching the provider credentials of the org it serves does it with
// an IAM key, which resolves to one org and no more. The rule reads the org the
// request acts in and never asks which door set it, so the key path needs no
// second statement of it — this is the test that the one statement covers both.
//
// It used to assert that a key reading its own org's stripe credentials was
// handed STRIPE_API_KEY from the process environment, and called that a key
// reaching its own org. It was the deployment's key, the same one for every
// org, and the test pinned the leak as the feature. What the key reaches now is
// its own org's KMS, which holds nothing here, so the read is a refusal to
// answer and never the platform's secret.
func TestAKeyActsInItsOwnOrg(t *testing.T) {
t.Setenv("STRIPE_API_KEY", "the-deployment-key")
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
defer app.Cleanup()
iam := newIssuer(t)
// The half of IAM a key resolves against, beside the JWKS one.
keys := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","data":{"name":"keyuser","email":"[email protected]","owner":"alpha"}}`))
}))
defer keys.Close()
if err := Register(app, Config{IAMEndpoint: keys.URL, KMSEndpoint: kmsHolding(t, ""),
IAMClientID: "svc", IAMClientSecret: "shh", IAMOrg: "hanzo"}); err != nil {
t.Fatal(err)
}
app.Store().Set("jwksURL", iam.url+"/v1/iam/.well-known/jwks")
db := NewOrgDB(app, "")
for _, org := range []string{"alpha", "beta"} {
if _, err := db.ProvisionOrg(org); err != nil {
t.Fatal(err)
}
}
mux := serve(t, app)
// Its own org is reached — not refused — and answers that it holds no such
// credential rather than handing over the deployment's.
code, body := call(t, mux, http.MethodGet, "/v1/bases/alpha/creds/stripe", "sk-service", nil)
if code == http.StatusForbidden {
t.Fatalf("a key was refused its own org: %d %s", code, body)
}
if strings.Contains(body, "the-deployment-key") {
t.Fatalf("a key was handed the deployment's own credential: %d %s", code, body)
}
for _, r := range orgRoutes {
path := strings.Replace(r.path, "%s", "beta", 1)
code, body := call(t, mux, r.method, path, "sk-service", nil)
if code != http.StatusForbidden {
t.Errorf("%s %s with an alpha key answered %d %s, want 403", r.method, path, code, body)
}
if strings.Contains(body, "the-deployment-key") {
t.Errorf("%s %s leaked a credential: %s", r.method, path, body)
}
}
}
// keyed stands up both halves of IAM: the one a key resolves against, and the
// issuer a token is verified against. Every key it answers for belongs to owner,
// under the username keyuser.
func keyed(t *testing.T, owner string, extra ...func(*core.ServeEvent)) (core.App, *issuer, http.Handler) {
t.Helper()
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
t.Cleanup(app.Cleanup)
iam := newIssuer(t)
keys := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","data":{"name":"keyuser","email":"[email protected]","owner":"` + owner + `"}}`))
}))
t.Cleanup(keys.Close)
if err := Register(app, Config{IAMEndpoint: keys.URL, KMSEndpoint: kmsHolding(t, ""),
IAMClientID: "svc", IAMClientSecret: "shh", IAMOrg: "hanzo"}); err != nil {
t.Fatal(err)
}
app.Store().Set("jwksURL", iam.url+"/v1/iam/.well-known/jwks")
db := NewOrgDB(app, "")
for _, org := range []string{"alpha", "beta"} {
if _, err := db.ProvisionOrg(org); err != nil {
t.Fatal(err)
}
}
return app, iam, serve(t, app, extra...)
}
// seedOrgConfig puts a real config row in, so a read that is admitted has
// something to hand back and the test measures a disclosure rather than a
// status code.
func seedOrgConfig(t *testing.T, app core.App, org, secret string) {
t.Helper()
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
}
c, err := app.FindCollectionByNameOrId(collectionOrgConfigs)
if err != nil {
t.Fatal(err)
}
r := core.NewRecord(c)
r.Set("org_id", org)
r.Set("display_name", org+" Inc")
r.Set("kms_project_id", secret)
if err := app.Save(r); err != nil {
t.Fatal(err)
}
}
// TestAPublishableKeyReachesNoBase pins what publishable means.
//
// A pk- key is the one that goes in a web page, and it travels in ?key=, so
// reaching a Base with it needs no Authorization header — the page source IS
// the credential. The only thing that stood in its way was a check that a
// publishable key may not WRITE, and every read here is a GET, so the key
// printed in a customer's HTML returned that org's provider secrets and every
// member's billing identity. Read-only was the wrong reading of publishable.
func TestAPublishableKeyReachesNoBase(t *testing.T) {
app, _, mux := keyed(t, "alpha", func(e *core.ServeEvent) {
// An address this file does not publish, registered the way an
// extension does it. What a publishable key reaches is a property of
// the prefix, so it holds for whatever route sits under it.
e.Router.GET("/v1/bases/{orgId}/usage", func(re *core.RequestEvent) error {
return re.JSON(http.StatusOK, map[string]string{"secret": "alpha-kms-project"})
})
})
seedOrgConfig(t, app, "alpha", "alpha-kms-project")
for _, r := range orgRoutes {
// Its OWN org, which is the point: the org rule is not what refuses it.
path := strings.Replace(r.path, "%s", "alpha", 1)
for _, how := range []struct{ name, token, query string }{
{"as a bearer", "pk-in-the-page", ""},
{"in the query", "", "?key=pk-in-the-page"},
} {
code, body := call(t, mux, r.method, path+how.query, how.token, nil)
if code != http.StatusForbidden {
t.Errorf("%s %s %s answered %d %s, want 403", r.method, path, how.name, code, body)
}
if strings.Contains(body, "alpha-kms-project") {
t.Errorf("%s %s %s handed a web page's key the org's config: %s", r.method, path, how.name, body)
}
}
}
for _, how := range []struct{ name, token, query string }{
{"as a bearer", "pk-in-the-page", ""},
{"in the query", "", "?key=pk-in-the-page"},
} {
code, body := call(t, mux, http.MethodGet, "/v1/bases/alpha/usage"+how.query, how.token, nil)
if code != http.StatusForbidden {
t.Errorf("a route registered off the router under /v1/bases answered a publishable key %s: %d %s", how.name, code, body)
}
if strings.Contains(body, "alpha-kms-project") {
t.Errorf("that route handed a web page's key the org's config %s: %s", how.name, body)
}
}
// The collection root too, which names no org and so has no wildcard in its
// address. What a publishable key reaches is a property of the prefix and
// not of any one segment in it.
if code, body := call(t, mux, http.MethodGet, basesPath, "pk-in-the-page", nil); code != http.StatusForbidden {
t.Errorf("the collection root answered a publishable key: %d %s", code, body)
}
// And it is refused by address rather than by being a pk- key at all: the
// same key still reaches what is not a Base.
if code, body := call(t, mux, http.MethodGet, "/v1/health", "pk-in-the-page", nil); code == http.StatusForbidden {
t.Errorf("a publishable key was refused something that is not a Base: %s", body)
}
}
// credRoutes is every address that reads or writes the org's own provider
// credentials.
var credRoutes = []struct{ method, path string }{
{http.MethodGet, "/v1/bases/alpha/creds/stripe"},
{http.MethodPost, "/v1/bases/alpha/creds/stripe"},
{http.MethodDelete, "/v1/bases/alpha/creds"},
}
// TestAMemberReachesNoOrgCredential pins that a provider credential belongs to
// the org and not to everyone in it.
//
// The key the org's Stripe account charges on is the org's. A member holding it
// can spend it; a member writing it points the org's integration at an account
// of their own. Membership is not authority over the org, which is the same
// distinction the customer route draws between a person and their colleagues.
func TestAMemberReachesNoOrgCredential(t *testing.T) {
_, iam, mux, _ := twoOrgs(t)
ann := iam.member(t, "alpha/ann", "alpha")
for _, r := range credRoutes {
code, body := call(t, mux, r.method, r.path, ann, nil)
if code != http.StatusForbidden {
t.Errorf("%s %s as a member answered %d %s, want 403", r.method, r.path, code, body)
}
}
// The org's own admin acts for the org and holds them.
boss := iam.token(t, "alpha/boss", "alpha")
for _, r := range credRoutes {
if code, body := call(t, mux, r.method, r.path, boss, nil); code == http.StatusForbidden {
t.Errorf("%s %s refused the org's own admin: %s", r.method, r.path, body)
}
}
}
// TestASecretKeyReachesItsOrgCredentials is the other half: an sk- key is the
// org's own server credential, so it acts for the org the way its admin does.
func TestASecretKeyReachesItsOrgCredentials(t *testing.T) {
_, _, mux := keyed(t, "alpha")
for _, r := range credRoutes {
if code, body := call(t, mux, r.method, r.path, "sk-the-orgs-own-key", nil); code == http.StatusForbidden {
t.Errorf("%s %s refused the org's own secret key: %s", r.method, r.path, body)
}
}
}
// TestAProviderNamesOneSegment pins the shape of what an address hands the KMS
// path composer.
//
// The KMS store keys on a path and the org is one segment of it, so which org
// owns a record is read out of that segment. A provider carrying a separator
// composes a coordinate instead of naming a folder — which is a spelling of a
// path only its writer chose — so it names no provider and stores nothing.
func TestAProviderNamesOneSegment(t *testing.T) {
_, iam, mux, _ := twoOrgs(t)
boss := iam.token(t, "alpha/boss", "alpha")
for _, provider := range []string{"x%2Fstripe", "..%2Fbeta%2Fstripe"} {
path := "/v1/bases/alpha/creds/" + provider
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"api_key":"alpha-key"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+boss)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("POST %s answered %d %s, want 400", path, rec.Code, rec.Body.String())
}
if code, body := call(t, mux, http.MethodGet, path, boss, nil); code == http.StatusOK {
t.Errorf("GET %s answered %d %s", path, code, body)
}
}
// A provider that names one segment reaches KMS, which is where the answer
// comes from — this deployment has none, so it is a dial and not a refusal.
if code, body := call(t, mux, http.MethodPost, "/v1/bases/alpha/creds/stripe", boss, nil); code == http.StatusBadRequest {
t.Errorf("a provider naming one segment was refused: %s", body)
}
}
// TestAMemberReachesOnlyItsOwnCustomerRow pins the second half of the customer
// route, which compared orgs and said nothing whatever about users.
//
// Belonging to an org is not the same fact as being a person in it. The row
// holds a customer id, a broker account, a commerce customer and a vault id, so
// an ordinary member reading a colleague's is a real disclosure inside a tenant
// that the org boundary cannot see.
func TestAMemberReachesOnlyItsOwnCustomerRow(t *testing.T) {
app, iam, mux, _ := twoOrgs(t)
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
}
ann := iam.member(t, "alpha/ann", "alpha")
code, body := call(t, mux, http.MethodGet, "/v1/bases/alpha/customers/alpha%2Fceo", ann, nil)
if code != http.StatusForbidden {
t.Errorf("a member read another member's row: %d %s", code, body)
}
code, body = call(t, mux, http.MethodPost, "/v1/bases/alpha/customers/alpha%2Fceo", ann, nil)
if code != http.StatusForbidden {
t.Errorf("a member provisioned another member's row: %d %s", code, body)
}
// Its own row is its own business.
if code, body := call(t, mux, http.MethodPost, "/v1/bases/alpha/customers/alpha%2Fann", ann, nil); code == http.StatusForbidden {
t.Errorf("a member was refused its own row: %s", body)
}
// An org's owner acts for the org and reaches every member's.
owner := iam.token(t, "alpha/boss", "alpha")
if code, body := call(t, mux, http.MethodGet, "/v1/bases/alpha/customers/alpha%2Fann", owner, nil); code == http.StatusForbidden {
t.Errorf("an org owner was refused a member's row: %s", body)
}
}
// TestADisplayNameNamesNobody pins which claim a person's address is composed
// from.
//
// caller() spells a person <owner>/<name>, the way IAM files one, and a customer
// row is addressed by that spelling. The <name> half is the account's USERNAME.
// A display name is free-form text the account holder writes, and base validates
// tokens against whichever identity provider a deployment points it at — so a
// name composed from one names whatever row its holder typed.
//
// A token that carries no username composes no address at all, which is the
// right answer: it names nobody, so it reaches nobody's row but its own subject.
func TestADisplayNameNamesNobody(t *testing.T) {
app, iam, mux, _ := twoOrgs(t)
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
}
// A member of alpha carrying no username, whose display name is the
// username half of another member's address.
ann := iam.display(t, "member", "alpha/ann", "ceo", "alpha")
for _, method := range []string{http.MethodGet, http.MethodPost} {
code, body := call(t, mux, method, "/v1/bases/alpha/customers/alpha%2Fceo", ann, nil)
if code != http.StatusForbidden {
t.Errorf("%s a colleague's row on a display name answered %d %s, want 403", method, code, body)
}
}
// The same account still reaches its own row, under the subject its token
// does carry.
if code, body := call(t, mux, http.MethodPost, "/v1/bases/alpha/customers/alpha%2Fann", ann, nil); code == http.StatusForbidden {
t.Errorf("an account was refused its own row: %s", body)
}
}
// TestTheRuleFollowsTheAddressAndNotTheGroup pins that what defends
// /v1/bases/{org} is the address and not the subtree it happened to be
// registered under.
//
// tools/router inherits middleware down the GROUP tree, not the URL. A rule
// stated on the group that declares these seven routes therefore covers those
// seven and nothing else, so a route registered off the router at the identical
// address carried none of it — and jsvm's routerAdd takes an arbitrary path
// string from an extension with no validation at all, which is exactly that
// registration.
func TestTheRuleFollowsTheAddressAndNotTheGroup(t *testing.T) {
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
t.Cleanup(app.Cleanup)
iam := newIssuer(t)
if err := Register(app, Config{IAMEndpoint: iam.url, KMSEndpoint: kmsHolding(t, ""),
IAMClientID: "svc", IAMClientSecret: "shh", IAMOrg: "hanzo"}); err != nil {
t.Fatal(err)
}
db := NewOrgDB(app, "")
for _, org := range []string{"alpha", "beta"} {
if _, err := db.ProvisionOrg(org); err != nil {
t.Fatal(err)
}
}
// Two routes at addresses this file does not publish, registered the way an
// extension does it. The second continues past a user, which is a shape
// only an extension writes.
echo := func(re *core.RequestEvent) error {
return re.JSON(http.StatusOK, map[string]string{"org": re.Request.PathValue("orgId")})
}
mux := serve(t, app, func(e *core.ServeEvent) {
e.Router.GET("/v1/bases/{orgId}/usage", echo)
e.Router.GET("/v1/bases/{orgId}/customers/{userId}/notes", echo)
})
alpha := iam.token(t, "alpha/ann", "alpha")
if code, body := call(t, mux, http.MethodGet, "/v1/bases/beta/usage", alpha, nil); code != http.StatusForbidden {
t.Errorf("a route registered off the router at a /v1/bases address answered %d %s, want 403", code, body)
}
if code, body := call(t, mux, http.MethodGet, "/v1/bases/alpha/usage", alpha, nil); code == http.StatusForbidden {
t.Errorf("the same route refused alpha its own org: %s", body)
}
// The user a path names is the caller, at whatever depth the address
// continues past it.
ann := iam.member(t, "alpha/ann", "alpha")
if code, body := call(t, mux, http.MethodGet, "/v1/bases/alpha/customers/alpha%2Fceo/notes", ann, nil); code != http.StatusForbidden {
t.Errorf("a member reached another member through a deeper address: %d %s", code, body)
}
if code, body := call(t, mux, http.MethodGet, "/v1/bases/alpha/customers/alpha%2Fann/notes", ann, nil); code == http.StatusForbidden {
t.Errorf("the same address refused a member itself: %s", body)
}
}
// TestASubtreeRouteBindsNoOrgAndIsRefused pins the third state an address under
// this prefix can be in.
//
// http.ServeMux reads a pattern ending in "/" as a SUBTREE, so /v1/bases/
// matches every address beneath it and binds nothing: the org sits in a later
// segment where a rule reading position 2 cannot see it. The rule then compared
// no org against the credential's org, and a caller with no org at all made
// that comparison true. jsvm's routerAdd takes a path string from an extension
// and validates none of it, so that pattern is one an extension can register.
//
// Only /v1/bases itself may name no org — it is the collection, and answers the
// membership question. Everywhere else under the prefix, an address that binds
// no org is one the rule cannot answer about, and it refuses.
func TestASubtreeRouteBindsNoOrgAndIsRefused(t *testing.T) {
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
t.Cleanup(app.Cleanup)
iam := newIssuer(t)
if err := Register(app, Config{IAMEndpoint: iam.url, KMSEndpoint: kmsHolding(t, ""),
IAMClientID: "svc", IAMClientSecret: "shh", IAMOrg: "hanzo"}); err != nil {
t.Fatal(err)
}
db := NewOrgDB(app, "")
for _, org := range []string{"alpha", "beta"} {
if _, err := db.ProvisionOrg(org); err != nil {
t.Fatal(err)
}
}
// The subtree pattern an extension can register, on a method the published
// routes do not take, so it is the pattern the router chooses.
mux := serve(t, app, func(e *core.ServeEvent) {
e.Router.Route(http.MethodPut, basesPath+"/", func(re *core.RequestEvent) error {
return re.JSON(http.StatusOK, map[string]string{"secret": "beta-provider-key"})
})
})
alpha := iam.token(t, "alpha/ann", "alpha")
for _, path := range []string{
basesPath + "/beta/creds/stripe",
basesPath + "/beta/customers/beta%2Fbob",
basesPath + "/beta",
} {
code, body := call(t, mux, http.MethodPut, path, alpha, nil)
if code != http.StatusForbidden {
t.Errorf("PUT %s as alpha answered %d %s, want 403", path, code, body)
}
if strings.Contains(body, "beta-provider-key") {
t.Errorf("PUT %s handed alpha what sits under beta: %s", path, body)
}
}
// A caller with no credential compares no org against no org, which is the
// comparison that used to match.
if code, body := call(t, mux, http.MethodPut, basesPath+"/beta/creds/stripe", "", nil); code != http.StatusForbidden {
t.Errorf("PUT with no credential answered %d %s, want 403", code, body)
}
// The collection itself still names no org, and still answers.
if code, body := call(t, mux, http.MethodGet, basesPath, alpha, nil); code == http.StatusForbidden {
t.Errorf("the collection root was refused: %s", body)
}
}
// requestLog waits for the row a served request writes and hands it back.
//
// The write is handed to a goroutine and then held by a batching handler, so it
// is neither synchronous with the response nor written on the first flush.
func requestLog(t *testing.T, app core.App) *core.Log {
t.Helper()
h, ok := app.SlogLogger().Handler().(*logger.BatchHandler)
if !ok {
t.Fatal("the app's log handler does not batch, so there is nothing to flush")
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if err := h.WriteAll(context.Background()); err != nil {
t.Fatal(err)
}
logs := []*core.Log{}
if err := app.LogQuery().All(&logs); err != nil {
t.Fatal(err)
}
for _, l := range logs {
if l.Data["type"] == "request" {
return l
}
}
time.Sleep(10 * time.Millisecond)
}
return nil
}
// TestTheAuditLogIsTheOperatorsAndCarriesNoSecret pins three things about one
// row, all of which were wrong at once.
//
// The row belongs on the Base the process serves from. logRequest read
// e.App, which a credential naming an org has already moved to that tenant's
// file, so an authenticated request logged itself into the tenant's Base and
// the operator's log held only traffic that never authenticated.
//
// It carries no credential. A key arrives in ?key= on a request with no
// Authorization header, and the row recorded RequestURI() whole — so a live
// secret key sat in _logs in plaintext for Logs.MaxDays, was served by
// GET /v1/logs, and went into every backup.
//
// And it names who acted. A key mints no auth record, so every keyed action was
// attributed to nobody at all.
func TestTheAuditLogIsTheOperatorsAndCarriesNoSecret(t *testing.T) {
app, _, mux := keyed(t, "alpha")
s := app.Settings()
s.Logs.MaxDays = 5
s.Logs.LogAuthId = true
if err := app.Save(s); err != nil {
t.Fatal(err)
}
const secret = "sk-live-never-in-a-log"
if code, body := call(t, mux, http.MethodGet, "/v1/health?key="+secret, "", nil); code != http.StatusOK {
t.Fatalf("the keyed request did not reach /v1/health: %d %s", code, body)
}
row := requestLog(t, app)
if row == nil {
t.Fatal("the deployment's log holds no row for a request its tenant served")
}
if url, _ := row.Data["url"].(string); strings.Contains(url, secret) {
t.Errorf("a live key was written to the log: %s", url)
}
if auth, _ := row.Data["auth"].(string); auth != "key" {
t.Errorf("a keyed request was attributed to %q", auth)
}
if id, _ := row.Data["authId"].(string); id != "alpha/keyuser" {
t.Errorf("a keyed request named the subject %q", id)
}
}
// TestTheRuleReadsTheAddressTheRouterMatched pins the rule and the handler to
// ONE reading of the path.
//
// http.ServeMux matches by splitting the escaped path on "/" and unescaping
// each segment, so every spelling below reaches the same handler with the same
// orgId. A rule that compares the escaped string against a literal prefix reads
// a different address than the router did, and the handler that runs is the
// router's — so the rule reads what the router matched: the pattern it chose
// and the values it filled.
func TestTheRuleReadsTheAddressTheRouterMatched(t *testing.T) {
app, iam, mux, _ := twoOrgs(t)
seedOrgConfig(t, app, "alpha", "alpha-kms-project")
seedOrgConfig(t, app, "beta", "beta-kms-project")
alpha := iam.token(t, "alpha/ann", "alpha")
ann := iam.member(t, "alpha/ann", "alpha")
// Every spelling of an address that routes to beta's config or beta's
// provider credentials, and what each must answer.
for _, c := range []struct{ name, path, token string }{
{"an encoded segment, uncredentialed", "/v1/%62ases/beta/config", ""},
{"an encoded prefix, uncredentialed", "/%76%31/%62%61%73%65%73/beta/config", ""},
{"an encoded segment, credentialed elsewhere", "/v1/%62ases/beta/config", alpha},
{"credentials by an encoded segment", "/v1/%62ases/beta/creds/stripe", ""},
{"credentials spelled plainly", "/v1/bases/beta/creds/stripe", ""},
{"the config spelled plainly", "/v1/bases/beta/config", ""},
} {
code, body := call(t, mux, http.MethodGet, c.path, c.token, nil)
if code != http.StatusForbidden {
t.Errorf("%s: GET %s answered %d %s, want 403", c.name, c.path, code, body)
}
if strings.Contains(body, "beta-kms-project") {
t.Errorf("%s: GET %s handed over beta's config: %s", c.name, c.path, body)
}
}
// A path that climbs reaches no org but its own. The router canonicalises
// one spelling before it matches and matches nothing at all on the other,
// so neither becomes a read of beta.
for _, path := range []string{
"/v1/bases/alpha/../beta/config",
"/v1/bases/alpha/%2e%2e/beta/config",
} {
code, body := call(t, mux, http.MethodGet, path, alpha, nil)
if code == http.StatusOK || strings.Contains(body, "beta-kms-project") {
t.Errorf("GET %s reached beta: %d %s", path, code, body)
}
}
// The member rule holds through an encoded address too: a user segment
// names a person however the prefix in front of it is spelled.
if code, body := call(t, mux, http.MethodGet,
"/v1/%62ases/alpha/customers/alpha%2Fceo", ann, nil); code != http.StatusForbidden {
t.Errorf("a member read another member's row through an encoded address: %d %s", code, body)
}
// And the org it does act in still answers, spelled either way.
for _, path := range []string{"/v1/bases/alpha/config", "/v1/%62ases/alpha/config"} {
code, body := call(t, mux, http.MethodGet, path, alpha, nil)
if code != http.StatusOK {
t.Errorf("GET %s refused alpha its own config: %d %s", path, code, body)
}
if !strings.Contains(body, "alpha-kms-project") {
t.Errorf("GET %s did not answer alpha its own config: %s", path, body)
}
}
}
// publicForm puts a collection anyone may create in into one org's Base, the
// way a contact form or a signup list is declared.
func publicForm(t *testing.T, dataDir, collection string) {
t.Helper()
app := core.NewBaseApp(core.BaseAppConfig{DataDir: dataDir})
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
}
defer app.ResetBootstrapState()
open := ""
c := core.NewBaseCollection(collection)
c.CreateRule = &open
c.Fields.Add(&core.TextField{Name: "email"})
if err := app.Save(c); err != nil {
t.Fatal(err)
}
}
// TestAPublishableKeyWritesOnlyAtTheCreateRoute pins the one write a publishable
// key is allowed, and the address that allows it.
//
// The exception is the public submit path — a collection whose createRule
// admits anyone — and what makes a request that path is the route the router
// matched. A string that merely appears somewhere in a URL is not an address,
// and granting the exception on one hands the key whatever the router did
// match. Neither is a pattern that merely ENDS the same way: an extension
// registers whatever path it likes, and a grant keyed on how a pattern ends is
// a grant every one of them inherits.
func TestAPublishableKeyWritesOnlyAtTheCreateRoute(t *testing.T) {
app, _, mux := keyed(t, "alpha", func(e *core.ServeEvent) {
// A POST at an address that is not the create route, registered the way
// an extension does it.
e.Router.POST("/v1/notes/{note}", func(re *core.RequestEvent) error {
return re.JSON(http.StatusOK, map[string]string{"wrote": re.Request.PathValue("note")})
})
// And one whose PATTERN ends exactly the way the create route's does.
e.Router.POST("/v1/ext/collections/{collection}/records", func(re *core.RequestEvent) error {
return re.JSON(http.StatusOK, map[string]string{"wrote": re.Request.PathValue("collection")})
})
})
publicForm(t, NewOrgDB(app, "").OrgDir("alpha"), "signups")
if code, body := call(t, mux, http.MethodPost, "/v1/collections/signups/records",
"pk-in-the-page", nil); code == http.StatusForbidden {
t.Errorf("a publishable key was refused the public submit path: %d %s", code, body)
}
if code, body := call(t, mux, http.MethodPost, "/v1/notes/..%2Fcollections%2Fsignups%2Frecords",
"pk-in-the-page", nil); code != http.StatusForbidden {
t.Errorf("a publishable key wrote at a route that is not the create route: %d %s", code, body)
}
if code, body := call(t, mux, http.MethodPost, "/v1/ext/collections/signups/records",
"pk-in-the-page", nil); code != http.StatusForbidden {
t.Errorf("a publishable key wrote at a route that only ENDS like the create route: %d %s", code, body)
}
}
// TestOneAccountIsOneCustomer pins the name a person is filed under.
//
// IAM's key endpoint answers with the account and never its opaque id, so a key
// can only name a person <owner>/<name>; a token carries the id in `sub` and
// the two halves of that name in `orgs` and `name`. Filed under whichever
// arrived, one human holds two customer rows in one org — two customer ids, two
// broker accounts, two vaults — and the KYC record bound through one door
// answers nobody at the other. So the name IAM files an account under is the
// name this package files a customer under, and both doors produce it.
func TestOneAccountIsOneCustomer(t *testing.T) {
app, iam, mux := keyed(t, "alpha")
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
}
// A token for the account the keys resolve to: IAM's opaque id in `sub`,
// the username in `name`, home org first.
const id = "9f3c21b8-7e4d-4a02-9c15-6b0f8d2e1a77"
self := iam.signed(t, "member", id, "keyuser", "alpha")
// The key opens the row. A secret key is the org's own, so it acts for the
// org and may open one for any member.
code, opened := call(t, mux, http.MethodPost, "/v1/bases/alpha/customers/alpha%2Fkeyuser", "sk-service", nil)
if code != http.StatusCreated {
t.Fatalf("a key could not open a customer row: %d %s", code, opened)
}
// The token reaches that same row rather than opening a second one.
code, read := call(t, mux, http.MethodPost, "/v1/bases/alpha/customers/alpha%2Fkeyuser", self, nil)
if code == http.StatusForbidden {
t.Fatalf("a token was refused the row its own key opened: %s", read)
}
if !sameCustomer(opened, read) {
t.Errorf("one account holds two customer rows:\n key: %s\n token: %s", opened, read)
}
// And it is recognised by the id its own token carries, which is the name a
// row opened before the two doors agreed is filed under.
if code, body := call(t, mux, http.MethodGet, "/v1/bases/alpha/customers/"+id, self, nil); code == http.StatusForbidden {
t.Errorf("a token was refused the name it carries for itself: %s", body)
}
// A colleague is still a colleague.
bob := iam.signed(t, "member", "3c7a0d51-2f88-4e63-b1aa-5d904c7e6b12", "bob", "alpha")
if code, body := call(t, mux, http.MethodGet, "/v1/bases/alpha/customers/alpha%2Fkeyuser", bob, nil); code != http.StatusForbidden {
t.Errorf("a member read another member's row: %d %s", code, body)
}
}
// sameCustomer reports whether two customer answers name one row.
func sameCustomer(a, b string) bool {
id := func(body string) string {
var c struct {
CustomerID string `json:"customer_id"`
}
_ = json.Unmarshal([]byte(body), &c)
return c.CustomerID
}
return id(a) != "" && id(a) == id(b)
}