Files
base/plugins/org/org_collections.go
zeekay 7cd428c222 what a credential reaches is a property of the address
Three sentences govern everything under /v1/bases, and all three are bound on the
router and read the path rather than stated on the group that declares those
seven routes. tools/router inherits middleware down the GROUP tree and not down
the URL, so a route registered off the router at the identical address carried
nothing — GET /v1/bases/beta/usage as alpha answered 200 while its guarded
sibling answered 403. Two live mechanisms register exactly that way: /v1/bases
itself, and jsvm's routerAdd, which takes a path string from an extension and
validates none of it. The comment claiming that stating the rule on the subtree
made a handler that forgets it impossible to write is replaced, because it was
false. It is the address that carries the data.

A publishable key now reaches none of it, refused by kind. pk- is the key you put
in a web page; it travels in ?key=, so it needs no Authorization header, and the
only thing in its way was a check that publishable keys may not WRITE while every
read here is a GET. The key printed in a customer's HTML returned that org's
provider credentials and every member's billing identity. Read-only was the wrong
reading of publishable. The method check stays as a floor.

And the user a path names must be the caller. /customers/{userId} compared orgs
and said nothing whatever about users, so an ordinary member's own token read any
colleague's customer id, broker account, commerce customer and vault. An org
admin, the org's own secret key and a platform operator act for the org and still
reach every row.

GetCreds no longer falls through to the process environment. It read
os.Getenv(PROVIDER + "_API_KEY") whenever KMS held no row for an org+provider,
which is every org that has not configured that provider — and provider is a path
segment the caller writes, so naming openai, anthropic or github returned the
DEPLOYMENT's secret for it, the one the KMSSecret CRDs inject. A test pinned that
as correct; it asserts the refusal now.

/v1/compliance names no org in its address, so nothing above reaches it and it
has to ask. It took applicationId from the path and called requireAuth, which
answers whether there is a caller and never whose it is, so one tenant read
another's KYC status and started verifications against it. The application is
recorded on the caller's customer row when it is created and checked on every
read. No deployment configures ComplianceEndpoint and nothing reads an env var
for it, so the surface is unreachable as shipped; this is what it needs before it
is not.

Also: OrgService leaves the store on tenant Bases, where an extension could have
read every other tenant's credentials by naming them; a revoked key expires on
its own clock, since a token carries an expiry and a key has none; and both
proxies refuse a path that climbs out of their mount, which a decoded %2e%2e did.
2026-08-11 16:08:25 -07:00

94 lines
3.3 KiB
Go

package org
import (
"github.com/hanzoai/base/core"
)
const (
collectionOrgConfigs = "_org_configs"
collectionOrgCustomers = "_org_customers"
)
func (p *plugin) ensureOrgConfigsCollection() error {
_, err := p.app.FindCollectionByNameOrId(collectionOrgConfigs)
if err == nil {
return nil
}
c := core.NewBaseCollection(collectionOrgConfigs)
c.System = true
c.Fields.Add(
&core.TextField{Name: "org_id", Required: true, Min: 1, Max: 100},
&core.TextField{Name: "display_name", Max: 200},
&core.SelectField{
Name: "status",
MaxSelect: 1,
Values: []string{"active", "suspended", "onboarding"},
},
&core.TextField{Name: "kms_project_id"},
&core.JSONField{Name: "fee_schedule", MaxSize: 1 << 20},
&core.JSONField{Name: "features", MaxSize: 1 << 20},
&core.JSONField{Name: "providers", MaxSize: 1 << 20},
&core.JSONField{Name: "chain_config", MaxSize: 1 << 20},
&core.JSONField{Name: "metadata", MaxSize: 1 << 20},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
// Unique index on org_id.
c.AddIndex("idx_org_configs_org_id", true, "org_id", "")
p.app.Logger().Info("creating platform system collection", "name", collectionOrgConfigs)
return p.app.Save(c)
}
// fieldComplianceApp holds the identity a (org, user) pair has at the
// compliance vendor. It is what makes "whose application is this?" answerable —
// the vendor's application id is a bare string in a URL and says nothing about
// who created it.
const fieldComplianceApp = "compliance_application_id"
func (p *plugin) ensureOrgCustomersCollection() error {
existing, err := p.app.FindCollectionByNameOrId(collectionOrgCustomers)
if err == nil {
// A collection created before this field existed still has to grow it,
// or every deployment already carrying customer rows would answer the
// ownership question with silence.
if existing.Fields.GetByName(fieldComplianceApp) != nil {
return nil
}
existing.Fields.Add(&core.TextField{Name: fieldComplianceApp})
return p.app.Save(existing)
}
c := core.NewBaseCollection(collectionOrgCustomers)
c.System = true
c.Fields.Add(
&core.TextField{Name: "org_id", Required: true, Min: 1, Max: 100},
&core.TextField{Name: "user_id", Required: true, Min: 1, Max: 100},
&core.TextField{Name: "customer_id", Required: true, Min: 1, Max: 20},
&core.SelectField{
Name: "status",
MaxSelect: 1,
Values: []string{"active", "suspended", "pending_kyc", "closed"},
},
&core.TextField{Name: "display_name", Max: 200},
&core.TextField{Name: "broker_account_id"},
&core.TextField{Name: "commerce_customer_id"},
&core.TextField{Name: "mpc_vault_id"},
&core.TextField{Name: fieldComplianceApp},
&core.JSONField{Name: "metadata", MaxSize: 1 << 20},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
// Unique composite index on (org_id, user_id).
c.AddIndex("idx_org_customers_org_user", true, "org_id, user_id", "")
// Unique composite index on (org_id, customer_id).
c.AddIndex("idx_org_customers_org_custid", true, "org_id, customer_id", "")
p.app.Logger().Info("creating platform system collection", "name", collectionOrgCustomers)
return p.app.Save(c)
}