mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
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:
+42
-2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user