mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
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
911 lines
36 KiB
Go
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)
|
|
}
|