Files
zeekay b3a9b68a09 the org a request names is the org it acts in
Seven routes under /v1/bases/{org} name an org in their path, and one sentence
governs all of them. Three stated it, four did not, and all three statements were
wrong or missing in a different way.

/config and /customers asked nothing at all, so any valid token read any org's
configuration and provisioned customers in any org. Against production,
GET /v1/bases/{someone-elses-org}/config answers 404 rather than 403 — the check
passed and the request went looking for data. It found none only because no org
has a config row yet, which is the whole of what stands between this and a leak.

/creds asked checkOrgAccess, which looked the org up in the local _orgs
collection and returned TRUE when it found nothing, on the reasoning that the org
"may be managed outside the org system". Every org is: _orgs is created by
ensureOrgsCollection and written by nothing, because orgs are IAM's and Base only
reads them off the validated token. So the collection is permanently empty, the
lookup permanently misses, and the guard permanently grants. Behind it,
GET /v1/bases/{org}/creds/{provider} returns that org's KMS credentials and POST
writes them — which redirects a victim org's provider traffic to an address the
attacker chooses.

The rule is now stated once, on the subtree, where a handler cannot forget it:
the org named in the path must be the org the request acts in. Which org that is
was already settled at the door, from the verified credential — home, an org the
membership set admits and the request selected with X-Org-Id, or any org for a
platform operator carrying the reserved admin membership. Comparing here rather
than deciding again is what keeps the two answers from drifting, and it holds for
the key door as well as the token door because both resolve the same value.

The refusal is 403 and never 404. A 404 answers a question about the data, so a
caller who reads one learns its token reaches that org.

_orgs and _org_members go with it, along with findMembership, addMember,
checkAccess and roleHasPermission. They were a second answer to "who is in this
org" that nothing ever wrote, and the emptiness that made them look harmless was
exactly what made the guard admit everyone. isValidSlug went too — validateSlug
is the one that runs, and a second spelling of a rule is a rule with two answers.
2026-08-11 12:45:45 -07:00

89 lines
2.0 KiB
Go

package org
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestOrgPrefix(t *testing.T) {
got := OrgPrefix("acme")
want := "t_acme_"
if got != want {
t.Errorf("OrgPrefix(\"acme\") = %q, want %q", got, want)
}
}
func TestScopedQuery(t *testing.T) {
got := ScopedQuery("acme", "tasks")
want := "t_acme_tasks"
if got != want {
t.Errorf("ScopedQuery(\"acme\", \"tasks\") = %q, want %q", got, want)
}
}
func TestIAMClientValidateToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/iam/oauth/userinfo" {
w.WriteHeader(http.StatusNotFound)
return
}
auth := r.Header.Get("Authorization")
if auth != "Bearer good-token" {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized"}`))
return
}
json.NewEncoder(w).Encode(IAMUser{
ID: "user123",
Email: "[email protected]",
Name: "Test User",
OrgIDs: []string{"org1", "org2"},
})
}))
defer server.Close()
client := NewIAMClient(server.URL)
// Valid token.
user, err := client.ValidateToken("good-token")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if user.ID != "user123" {
t.Errorf("expected user id user123, got %s", user.ID)
}
if user.Email != "[email protected]" {
t.Errorf("expected email [email protected], got %s", user.Email)
}
if len(user.OrgIDs) != 2 {
t.Errorf("expected 2 org IDs, got %d", len(user.OrgIDs))
}
// Should be cached.
user2, err := client.ValidateToken("good-token")
if err != nil {
t.Fatalf("cached lookup failed: %v", err)
}
if user2.ID != "user123" {
t.Errorf("cached user id mismatch")
}
// Invalid token.
_, err = client.ValidateToken("bad-token")
if err == nil {
t.Fatal("expected error for bad token")
}
// Invalidate and re-validate.
client.InvalidateToken("good-token")
user3, err := client.ValidateToken("good-token")
if err != nil {
t.Fatalf("re-validation failed: %v", err)
}
if user3.ID != "user123" {
t.Errorf("re-validated user id mismatch")
}
}