An org admin changes its own Base's schema

The collection routes admit an org admin in its own org's Base as well as a
superuser. An org admin creates, changes, deletes, truncates and imports
the org's ordinary base collections only, judged as each is and as the
request would leave it: auth, view and system collections, Base's
underscore tables, deleteMissing and token-secret rotation stay a
superuser's. Every org-admin schema change is logged.

The superuser-mirror test probed /v1/collections, which an org admin now
reaches; it probes /v1/settings.
This commit is contained in:
2026-10-18 21:25:32 -07:00
parent dd46954b01
commit 3fdb4d5d7d
5 changed files with 296 additions and 4 deletions
+42 -2
View File
@@ -14,12 +14,15 @@ import (
// bindCollectionApi registers the collection api endpoints and the corresponding handlers.
func bindCollectionApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
subGroup := rg.Group("/collections").Bind(RequireSuperuserAuth())
// A superuser, or an org admin in its own org's Base: the org's schema is
// the org's to change. Which collections an org admin may touch is each
// route's to ask (refuseUnmanageable); a token secret is not among them.
subGroup := rg.Group("/collections").Bind(RequireSchemaAuth())
subGroup.GET("", collectionsList)
subGroup.POST("", collectionCreate)
subGroup.GET("/{collection}", collectionView)
subGroup.PATCH("/{collection}", collectionUpdate)
subGroup.POST("/{collection}/rotate", collectionRotate)
subGroup.POST("/{collection}/rotate", collectionRotate).Bind(RequireSuperuserAuth())
subGroup.DELETE("/{collection}", collectionDelete)
subGroup.DELETE("/{collection}/truncate", collectionTruncate)
subGroup.PUT("/import", collectionsImport)
@@ -89,11 +92,19 @@ func collectionCreate(e *core.RequestEvent) error {
return e.BadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
if err := refuseUnmanageable(e, "create", collection); err != nil {
return err
}
event := new(core.CollectionRequestEvent)
event.RequestEvent = e
event.Collection = collection
return e.App.OnCollectionCreateRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
if err := refuseUnmanageable(e.RequestEvent, "create", e.Collection); err != nil {
return err
}
if err := e.App.Save(e.Collection); err != nil {
// validation failure
var validationErrors validation.Errors
@@ -105,6 +116,8 @@ func collectionCreate(e *core.RequestEvent) error {
return e.BadRequestError("Failed to create collection. Raw error: \n"+err.Error(), nil)
}
logOrgAdminSchemaChange(e.RequestEvent, "create", e.Collection.Name)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Collection)
})
@@ -117,15 +130,28 @@ func collectionUpdate(e *core.RequestEvent) error {
return e.NotFoundError("", err)
}
if err := refuseUnmanageable(e, "change", collection); err != nil {
return err
}
if err := e.BindBody(collection); err != nil {
return e.BadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
// and as the change would leave it
if err := refuseUnmanageable(e, "change", collection); err != nil {
return err
}
event := new(core.CollectionRequestEvent)
event.RequestEvent = e
event.Collection = collection
return event.App.OnCollectionUpdateRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
if err := refuseUnmanageable(e.RequestEvent, "change", e.Collection); err != nil {
return err
}
if err := e.App.Save(e.Collection); err != nil {
// validation failure
var validationErrors validation.Errors
@@ -137,6 +163,8 @@ func collectionUpdate(e *core.RequestEvent) error {
return e.BadRequestError("Failed to update collection. Raw error: \n"+err.Error(), nil)
}
logOrgAdminSchemaChange(e.RequestEvent, "update", e.Collection.Name)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Collection)
})
@@ -179,6 +207,10 @@ func collectionDelete(e *core.RequestEvent) error {
return e.NotFoundError("", err)
}
if err := refuseUnmanageable(e, "delete", collection); err != nil {
return err
}
event := new(core.CollectionRequestEvent)
event.RequestEvent = e
event.Collection = collection
@@ -200,6 +232,8 @@ func collectionDelete(e *core.RequestEvent) error {
return e.BadRequestError(msg, err)
}
logOrgAdminSchemaChange(e.RequestEvent, "delete", e.Collection.Name)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
@@ -216,11 +250,17 @@ func collectionTruncate(e *core.RequestEvent) error {
return e.BadRequestError("View collections cannot be truncated since they don't store their own records.", nil)
}
if err := refuseUnmanageable(e, "truncate", collection); err != nil {
return err
}
err = e.App.TruncateCollection(collection)
if err != nil {
return e.BadRequestError("Failed to truncate collection (most likely due to required cascade delete record references).", err)
}
logOrgAdminSchemaChange(e, "truncate", collection.Name)
return e.NoContent(http.StatusNoContent)
}
+23
View File
@@ -21,6 +21,27 @@ func collectionsImport(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
}
// An org admin grows and changes its own base collections. It deletes none
// by omission: deleteMissing drops every collection the document leaves
// out, the ones that are not the org's to touch among them.
var names []string
if !e.HasSuperuserAuth() {
if form.DeleteMissing {
return e.ForbiddenError("An org admin cannot import with deleteMissing.", nil)
}
current, after, err := importedCollections(e.App, form.Collections)
if err != nil {
return e.BadRequestError("An error occurred while loading the submitted data.", err)
}
if err := refuseUnmanageable(e, "import", append(current, after...)...); err != nil {
return err
}
for _, c := range after {
names = append(names, c.Name)
}
}
event := new(core.CollectionsImportRequestEvent)
event.RequestEvent = e
event.CollectionsData = form.Collections
@@ -29,6 +50,8 @@ func collectionsImport(e *core.RequestEvent) error {
return event.App.OnCollectionsImportRequest().Trigger(event, func(e *core.CollectionsImportRequestEvent) error {
importErr := e.App.ImportCollections(e.CollectionsData, form.DeleteMissing)
if importErr == nil {
logOrgAdminSchemaChange(e.RequestEvent, "import", names...)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
+92
View File
@@ -0,0 +1,92 @@
package apis
import (
"encoding/json"
"fmt"
"github.com/hanzoai/base/core"
"github.com/spf13/cast"
)
// An org admin changes its own org's schema through the same routes a superuser
// does, over the collections that are the org's to change: ordinary base
// collections (SchemaManageable). Every check below is asked of the collection
// as it is and as the request would leave it, so no change can turn a
// collection the org may not touch into one it may, or the reverse.
//
// A superuser is never asked: its authority is the platform's and wider than
// any one org's, and it is never reached through an org admin's.
// refuseUnmanageable answers 403 when a request that is not a superuser's would
// touch a collection an org admin may not change.
func refuseUnmanageable(e *core.RequestEvent, action string, collections ...*core.Collection) error {
if e.HasSuperuserAuth() {
return nil
}
for _, c := range collections {
if !SchemaManageable(c) {
name := ""
if c != nil {
name = c.Name
}
return e.ForbiddenError(fmt.Sprintf("An org admin cannot %s the collection %q: only the org's own base collections are its to change.", action, name), nil)
}
}
return nil
}
// logOrgAdminSchemaChange records a schema change an org admin made.
func logOrgAdminSchemaChange(e *core.RequestEvent, action string, collections ...string) {
if e.HasSuperuserAuth() {
return
}
org, _ := e.Get(RequestEventKeyOrg).(string)
sub, _ := e.Get(RequestEventKeySub).(string)
e.App.Logger().Info("base: schema change by an org admin",
"org", org,
"sub", sub,
"action", action,
"collections", collections,
)
}
// importedCollections is every collection an import document names, as it is
// now (nil for one the import would create) and as the import would leave it.
func importedCollections(app core.App, docs []map[string]any) (current, after []*core.Collection, err error) {
for _, doc := range docs {
identifier := cast.ToString(doc["id"])
if identifier == "" {
identifier = cast.ToString(doc["name"])
}
var would *core.Collection
existing, _ := app.FindCollectionByNameOrId(identifier)
if existing != nil {
current = append(current, existing)
// a fresh copy, so the unmarshal below leaves the cached one alone
would, err = app.FindCollectionByNameOrId(existing.Id)
if err != nil {
return nil, nil, err
}
} else {
would = core.NewCollection(cast.ToString(doc["type"]), cast.ToString(doc["name"]), cast.ToString(doc["id"]))
}
raw, err := json.Marshal(doc)
if err != nil {
return nil, nil, err
}
if err := json.Unmarshal(raw, would); err != nil {
return nil, nil, err
}
after = append(after, would)
}
return current, after, nil
}
+135
View File
@@ -0,0 +1,135 @@
package apis_test
import (
"net/http"
"strings"
"testing"
"github.com/hanzoai/base/apis"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tests"
)
// orgBase readies a test app as an org's Base: IAM is its only auth, and it
// holds a collection of the org's own and a system one.
func orgBase(t testing.TB, app *tests.TestApp, jwksURL, tenant string) {
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
app.Store().Set(apis.StoreKeyJWKSURL, jwksURL)
if tenant != "" {
app.Store().Set(apis.StoreKeyTenant, tenant)
}
own := core.NewBaseCollection("leads")
own.Fields.Add(&core.TextField{Name: "title"})
if err := app.Save(own); err != nil {
t.Fatal(err)
}
sys := core.NewBaseCollection("sysbox")
sys.System = true
sys.Fields.Add(&core.TextField{Name: "title"})
if err := app.Save(sys); err != nil {
t.Fatal(err)
}
}
// An org admin changes its own Base's schema; a member does not; and what an
// org admin may change is the org's own base collections and nothing Base keeps
// for itself.
func TestOrgAdminSchemaAuthority(t *testing.T) {
t.Parallel()
type call struct {
name, method, url, body string
status int
content string
}
member := []call{
{"member lists", http.MethodGet, "/v1/collections", "", 403, `"status":403`},
{"member creates", http.MethodPost, "/v1/collections", `{"name":"mine","type":"base"}`, 403, `"status":403`},
{"member updates", http.MethodPatch, "/v1/collections/leads", `{"fields":[{"name":"title","type":"text"},{"name":"stage","type":"text"}]}`, 403, `"status":403`},
{"member deletes", http.MethodDelete, "/v1/collections/leads", "", 403, `"status":403`},
{"member truncates", http.MethodDelete, "/v1/collections/leads/truncate", "", 403, `"status":403`},
{"member imports", http.MethodPut, "/v1/collections/import", `{"collections":[{"name":"mine","type":"base"}]}`, 403, `"status":403`},
}
admin := []call{
{"admin lists", http.MethodGet, "/v1/collections?filter=name%3D%27leads%27", "", 200, `"name":"leads"`},
{"admin views a system collection", http.MethodGet, "/v1/collections/users", "", 200, `"name":"users"`},
{"admin creates a base collection", http.MethodPost, "/v1/collections",
`{"name":"deals","type":"base","fields":[{"name":"title","type":"text","label":"Title"}]}`, 200, `"name":"deals"`},
{"admin adds a field", http.MethodPatch, "/v1/collections/leads",
`{"fields":[{"name":"title","type":"text"},{"name":"stage","type":"select","values":["new","won"],"maxSelect":1}]}`, 200, `"name":"stage"`},
{"admin deletes its collection", http.MethodDelete, "/v1/collections/leads", "", 204, ""},
{"admin truncates its collection", http.MethodDelete, "/v1/collections/leads/truncate", "", 204, ""},
{"admin imports a new collection", http.MethodPut, "/v1/collections/import",
`{"collections":[{"name":"imported","type":"base","fields":[{"name":"title","type":"text"}]}]}`, 204, ""},
{"admin cannot change users", http.MethodPatch, "/v1/collections/users", `{"listRule":""}`, 403, `cannot change the collection \"users\"`},
{"admin cannot change _superusers", http.MethodPatch, "/v1/collections/_superusers", `{"listRule":""}`, 403, `"status":403`},
{"admin cannot change _history", http.MethodPatch, "/v1/collections/_history", `{"listRule":""}`, 403, `"status":403`},
{"admin cannot change a system collection", http.MethodPatch, "/v1/collections/sysbox", `{"listRule":""}`, 403, `"status":403`},
{"admin cannot delete a system collection", http.MethodDelete, "/v1/collections/sysbox", "", 403, `"status":403`},
{"admin cannot truncate users", http.MethodDelete, "/v1/collections/users/truncate", "", 403, `"status":403`},
{"admin cannot rename into Base's own names", http.MethodPatch, "/v1/collections/leads", `{"name":"_leads"}`, 403, `"status":403`},
{"admin cannot create a view", http.MethodPost, "/v1/collections",
`{"name":"v","type":"view","viewQuery":"select id from leads"}`, 403, `"status":403`},
{"admin cannot create an auth collection", http.MethodPost, "/v1/collections", `{"name":"staff","type":"auth"}`, 403, `"status":403`},
{"admin cannot create a system collection", http.MethodPost, "/v1/collections", `{"name":"core","type":"base","system":true}`, 403, `"status":403`},
{"admin cannot import with deleteMissing", http.MethodPut, "/v1/collections/import",
`{"deleteMissing":true,"collections":[{"name":"leads","type":"base"}]}`, 403, `deleteMissing`},
{"admin cannot import over users", http.MethodPut, "/v1/collections/import",
`{"collections":[{"name":"users","listRule":""}]}`, 403, `"status":403`},
{"admin cannot rotate token secrets", http.MethodPost, "/v1/collections/users/rotate", "", 403, `"status":403`},
}
run := func(t *testing.T, org, role, tenant string, calls []call) {
for _, c := range calls {
token, jwksURL := iamCaller(t, org, role)
var body *strings.Reader
if c.body != "" {
body = strings.NewReader(c.body)
}
s := tests.ApiScenario{
Name: role + " of " + org + ": " + c.name,
Method: c.method,
URL: c.url,
Headers: map[string]string{"Authorization": "Bearer " + token},
ExpectedStatus: c.status,
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
orgBase(t, app, jwksURL, tenant)
},
}
if body != nil {
s.Body = body
}
if c.content != "" {
s.ExpectedContent = []string{c.content}
}
s.Test(t)
}
}
run(t, "acme", "member", "acme", member)
run(t, "acme", "admin", "acme", admin)
run(t, "acme", "owner", "acme", admin[:3])
// an admin of another org reaches this Base's schema no more than its records
run(t, "zoo", "owner", "acme", []call{
{"another org's admin creates", http.MethodPost, "/v1/collections", `{"name":"mine","type":"base"}`, 403, `"status":403`},
{"another org's admin lists", http.MethodGet, "/v1/collections", "", 403, `"status":403`},
})
anonymous := tests.ApiScenario{
Name: "anonymous",
Method: http.MethodPost,
URL: "/v1/collections",
Body: strings.NewReader(`{"name":"mine","type":"base"}`),
ExpectedStatus: 401,
ExpectedContent: []string{`"status":401`},
}
anonymous.Test(t)
}
+4 -2
View File
@@ -905,7 +905,7 @@ func TestIAMSuperuserMirror(t *testing.T) {
},
},
status: 200,
content: []string{`"totalItems"`, `"_superusers"`},
content: []string{`"meta"`, `"rateLimits"`},
},
{
// SuperAdmin is the person's own org. A brand org's admin added to the
@@ -992,13 +992,15 @@ func TestIAMSuperuserMirror(t *testing.T) {
},
}
// The probe is a route only a superuser reaches. /v1/collections was one,
// until an org admin could change its own Base's schema there.
for _, s := range scenarios {
token, jwksURL := mintIAMToken(t, s.claims)
scenario := tests.ApiScenario{
Name: s.name,
Method: http.MethodGet,
URL: "/v1/collections",
URL: "/v1/settings",
Headers: map[string]string{"Authorization": "Bearer " + token},
ExpectedStatus: s.status,
ExpectedContent: s.content,