mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
A collection can admit visitors from other orgs
A Base its host opened for one org refused every token acting in another, so a reader signed in with Hanzo ID could not comment on a site whose Base is org hanzo's. Opening that org to any account would hand the CRM to anyone, since its rules admit any signed-in caller. A base collection now admits visitors when it says so, with "visitors": true. A visitor reaches only its records and files; every other route, and every collection that has not opted in, still answers 403. Its record carries role "visitor", admin false and its own org, so `author = @request.auth.id` holds a row to its writer. Any other collection's rules read a visitor as a guest, so a relation, expand or filter from a visitors' collection carries it no further. Its writes are held to ten a minute per Base, keyed by subject, whatever the deployment limits. A superuser is never a visitor.
This commit is contained in:
@@ -96,6 +96,15 @@ refuses a plaintext file.
|
||||
`RequestEvent.App` at the org's Base before any handler runs, so handlers know
|
||||
nothing about tenancy.
|
||||
|
||||
**A visitor reaches only what admits one.** A Base whose host opened it for
|
||||
one org (`StoreKeyTenant`) refuses a token acting in another org, except on the
|
||||
routes in `visitorRoutes` (records and files) of a base collection with
|
||||
`visitors: true` (`Collection.AdmitsVisitors`). The visitor's mirrored record
|
||||
has role `visitor` (`core.IsVisitor`) and admin false, and every other
|
||||
collection's rules read it as a guest (`processRequestAuthField`), so a
|
||||
relation, expand or filter cannot carry it further. Its writes go through
|
||||
`holdTo` keyed by sub. A superuser is never a visitor.
|
||||
|
||||
**Rules are enforced in Go, on every engine.** `nil` is superuser only and `""`
|
||||
is public. A list rule is ANDed into the query before paging and counting. An
|
||||
update is checked against the record as it would be after the write
|
||||
|
||||
+49
-5
@@ -470,10 +470,19 @@ func resolveJWKSToken(e *core.RequestEvent, token, jwksURL string) (*core.Record
|
||||
return nil, refusal{err}
|
||||
}
|
||||
|
||||
// A Base its host opened for one org answers that org and no other.
|
||||
if tenant, _ := e.App.Store().Get(StoreKeyTenant).(string); tenant != "" && bases == nil && tenant != org {
|
||||
// A Base its host opened for one org answers that org, and anyone else only
|
||||
// as a visitor: on a route visitors may take, to a collection that admits
|
||||
// them. A superuser is never one; it acts in a tenant by naming it.
|
||||
tenant, _ := e.App.Store().Get(StoreKeyTenant).(string)
|
||||
visitor := tenant != "" && bases == nil && tenant != org
|
||||
if visitor && (verified.Sudo() || !visits(e)) {
|
||||
return nil, refusal{router.NewForbiddenError("The token does not act in this Base's organization.", nil)}
|
||||
}
|
||||
if visitor && e.Request.Method != http.MethodGet && e.Request.Method != http.MethodHead {
|
||||
if err := holdTo(e, "__visitor__", visitorWrites, sub); err != nil {
|
||||
return nil, refusal{err}
|
||||
}
|
||||
}
|
||||
|
||||
// Acting anywhere but home is either an explicit selection the membership
|
||||
// set admits or a platform operator reaching a tenant, which is the one
|
||||
@@ -499,7 +508,7 @@ func resolveJWKSToken(e *core.RequestEvent, token, jwksURL string) (*core.Record
|
||||
e.Set(RequestEventKeyName, username)
|
||||
e.Set(RequestEventKeyOrgs, orgsOf(verified))
|
||||
e.Set(RequestEventKeyOrg, org)
|
||||
e.Set(RequestEventKeyOrgAdmin, verified.OrgAdmin(org))
|
||||
e.Set(RequestEventKeyOrgAdmin, !visitor && verified.OrgAdmin(org))
|
||||
e.Request.Header.Set(claims.HeaderUserID, sub)
|
||||
e.Request.Header.Set(claims.HeaderOrgID, org)
|
||||
e.Request.Header.Set(headerUserEmail, email)
|
||||
@@ -535,9 +544,16 @@ func resolveJWKSToken(e *core.RequestEvent, token, jwksURL string) (*core.Record
|
||||
record.Set("org_id", org)
|
||||
}
|
||||
if collection.Fields.GetByName(core.FieldNameAuthAdmin) != nil {
|
||||
record.Set(core.FieldNameAuthAdmin, verified.OrgAdmin(org))
|
||||
record.Set(core.FieldNameAuthAdmin, !visitor && verified.OrgAdmin(org))
|
||||
}
|
||||
if collection.Fields.GetByName(core.FieldNameAuthRole) != nil {
|
||||
switch {
|
||||
case visitor && collection.Fields.GetByName(core.FieldNameAuthRole) == nil:
|
||||
// The role is what marks a visitor to the rules, so a Base that cannot
|
||||
// record one admits no visitor.
|
||||
return nil, refusal{router.NewForbiddenError("The token does not act in this Base's organization.", nil)}
|
||||
case visitor:
|
||||
record.Set(core.FieldNameAuthRole, core.AuthRoleVisitor)
|
||||
case collection.Fields.GetByName(core.FieldNameAuthRole) != nil:
|
||||
record.Set(core.FieldNameAuthRole, roleIn(verified, org))
|
||||
}
|
||||
record.SetVerified(true)
|
||||
@@ -545,6 +561,34 @@ func resolveJWKSToken(e *core.RequestEvent, token, jwksURL string) (*core.Record
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// visitorRoutes are the routes a visitor may take, beneath the API prefix: a
|
||||
// collection's records and the files they hold. Nothing else admits one — not
|
||||
// the schema, settings, batch, realtime, aggregates, history or a file token.
|
||||
var visitorRoutes = []string{
|
||||
http.MethodGet + " " + recordsPath,
|
||||
http.MethodGet + " " + recordsPath + "/{id}",
|
||||
http.MethodPost + " " + recordsPath,
|
||||
http.MethodPatch + " " + recordsPath + "/{id}",
|
||||
http.MethodDelete + " " + recordsPath + "/{id}",
|
||||
http.MethodGet + " /files/{collection}/{recordId}/{filename}",
|
||||
}
|
||||
|
||||
// visitorWrites holds each visitor to ten writes a minute in a Base, whatever
|
||||
// the deployment's own limits say, counted by who the visitor is rather than
|
||||
// where the request came from.
|
||||
var visitorWrites = core.RateLimitRule{Label: "visitor", MaxRequests: 10, Duration: 60}
|
||||
|
||||
// visits reports whether a request is one a visitor may make: one of
|
||||
// visitorRoutes, to a collection of this Base that admits visitors.
|
||||
func visits(e *core.RequestEvent) bool {
|
||||
method, path, _ := strings.Cut(e.Request.Pattern, " ")
|
||||
if !slices.Contains(visitorRoutes, method+" "+strings.TrimPrefix(path, Prefix())) {
|
||||
return false
|
||||
}
|
||||
c, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
|
||||
return err == nil && c.AdmitsVisitors()
|
||||
}
|
||||
|
||||
// roleIn is the role the claims hold in org, as IAM's coarse vocabulary names
|
||||
// it (owner, admin or member), for a rule to read as `@request.auth.role`. A
|
||||
// membership that names no role is a member's.
|
||||
|
||||
@@ -131,6 +131,26 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
|
||||
}
|
||||
}
|
||||
|
||||
// A limit is per client, so a request that names no client cannot be held
|
||||
// to one and is refused. The alternative is a rule that admits whatever it
|
||||
// cannot count, which reads as enforcement and is not.
|
||||
//
|
||||
// A socket peer is where the address usually comes from, and a transport
|
||||
// that terminates elsewhere and hands the request on has none. Where the
|
||||
// deployment names a header to read it from instead — Settings.TrustedProxy
|
||||
// — there is a client again and the rule applies to it.
|
||||
key := e.RealIP()
|
||||
if key == "" {
|
||||
e.Deployment().Logger().Warn("Rate limited request carries no client address", "rule", rule.String())
|
||||
return e.TooManyRequestsError("", errors.New("no client address for rate limit rule: "+rule.String()))
|
||||
}
|
||||
|
||||
return holdTo(e, rtId, rule, key)
|
||||
}
|
||||
|
||||
// holdTo counts one request from the client key names against rule, in the
|
||||
// limiter rtId names, and refuses it once the client is over.
|
||||
func holdTo(e *core.RequestEvent, rtId string, rule core.RateLimitRule, key string) error {
|
||||
// One set of counters, on the Base the process serves from. Per-tenant
|
||||
// counters would multiply every rule by the number of orgs a caller can
|
||||
// name, so a limit of 2 would admit 2 per org and the number the operator
|
||||
@@ -153,20 +173,6 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
|
||||
return nil
|
||||
}
|
||||
|
||||
// A limit is per client, so a request that names no client cannot be held
|
||||
// to one and is refused. The alternative is a rule that admits whatever it
|
||||
// cannot count, which reads as enforcement and is not.
|
||||
//
|
||||
// A socket peer is where the address usually comes from, and a transport
|
||||
// that terminates elsewhere and hands the request on has none. Where the
|
||||
// deployment names a header to read it from instead — Settings.TrustedProxy
|
||||
// — there is a client again and the rule applies to it.
|
||||
key := e.RealIP()
|
||||
if key == "" {
|
||||
app.Logger().Warn("Rate limited request carries no client address", "rule", rule.String())
|
||||
return e.TooManyRequestsError("", errors.New("no client address for rate limit rule: "+rule.String()))
|
||||
}
|
||||
|
||||
if !rt.isAllowed(key) {
|
||||
return e.TooManyRequestsError("", errors.New("triggered rate limit rule: "+rule.String()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/hanzoai/base/apis"
|
||||
"github.com/hanzoai/base/core"
|
||||
"github.com/hanzoai/base/tests"
|
||||
"github.com/hanzoai/base/tools/types"
|
||||
)
|
||||
|
||||
// The record ids Base gives the subjects below: a short lowercase sub, padded.
|
||||
const (
|
||||
visitorID = "vic____________"
|
||||
memberID = "mem____________"
|
||||
)
|
||||
|
||||
// visitorBase is a Base its host opened for acme, holding a collection that
|
||||
// admits visitors (comments) beside ones that do not (people, lure), and a
|
||||
// visitor from zoo who owns zoo, a member of acme and a SuperAdmin to call it.
|
||||
type visitorBase struct {
|
||||
app *tests.TestApp
|
||||
mux http.Handler
|
||||
visitor, member, admin string
|
||||
person, comment string
|
||||
}
|
||||
|
||||
func newVisitorBase(t *testing.T) *visitorBase {
|
||||
t.Helper()
|
||||
|
||||
app, err := tests.NewTestApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(app.Cleanup)
|
||||
|
||||
iam := tests.NewIAM(t)
|
||||
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
|
||||
app.Store().Set(apis.StoreKeyJWKSURL, iam.URL)
|
||||
app.Store().Set(apis.StoreKeyTenant, "acme")
|
||||
|
||||
signedIn := types.Pointer(`@request.auth.id != ""`)
|
||||
people := core.NewBaseCollection("people")
|
||||
people.ListRule, people.ViewRule, people.CreateRule, people.UpdateRule, people.DeleteRule = signedIn, signedIn, signedIn, signedIn, signedIn
|
||||
people.Fields.Add(&core.TextField{Name: "name"}, &core.FileField{Name: "doc", MaxSelect: 1, MaxSize: 1 << 20})
|
||||
if err := app.Save(people); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
own := types.Pointer(`author = @request.auth.id`)
|
||||
comments := core.NewBaseCollection("comments")
|
||||
comments.Visitors = true
|
||||
comments.ListRule, comments.ViewRule = types.Pointer(""), types.Pointer("")
|
||||
comments.CreateRule = types.Pointer(`@request.auth.id != "" && author = @request.auth.id`)
|
||||
comments.UpdateRule, comments.DeleteRule = own, own
|
||||
comments.Fields.Add(
|
||||
&core.TextField{Name: "author"},
|
||||
&core.TextField{Name: "body", Max: 4000},
|
||||
&core.RelationField{Name: "ref", CollectionId: people.Id, MaxSelect: 1},
|
||||
&core.FileField{Name: "doc", MaxSelect: 1, MaxSize: 1 << 20},
|
||||
)
|
||||
if err := app.Save(comments); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A rule that names visitors opens nothing on a collection that does not
|
||||
// admit them.
|
||||
lure := core.NewBaseCollection("lure")
|
||||
lure.ListRule = types.Pointer(`@request.auth.role = "visitor"`)
|
||||
if err := app.Save(lure); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
person := core.NewRecord(people)
|
||||
person.Set("name", "Acme Corp")
|
||||
if err := app.Save(person); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
comment := core.NewRecord(comments)
|
||||
comment.Set("author", memberID)
|
||||
comment.Set("body", "first")
|
||||
comment.Set("ref", person.Id)
|
||||
if err := app.Save(comment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router, err := apis.NewRouter(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux, err := router.BuildMux()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return &visitorBase{
|
||||
app: app,
|
||||
mux: mux,
|
||||
visitor: iam.Token(t, jwt.MapClaims{"sub": "vic", "name": "Vic", "email": "[email protected]",
|
||||
"orgs": []any{map[string]any{"org": "zoo", "role": "owner"}}}),
|
||||
member: iam.Token(t, jwt.MapClaims{"sub": "mem", "name": "Mem", "email": "[email protected]",
|
||||
"orgs": []any{map[string]any{"org": "acme", "role": "member"}}}),
|
||||
admin: iam.Token(t, jwt.MapClaims{"sub": "root", "name": "root", "email": "[email protected]",
|
||||
"orgs": []any{map[string]any{"org": "admin", "role": "owner"}}}),
|
||||
person: person.Id,
|
||||
comment: comment.Id,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *visitorBase) do(t *testing.T, token, method, target, body string, headers ...string) (int, string) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
b.mux.ServeHTTP(rec, req)
|
||||
return rec.Code, rec.Body.String()
|
||||
}
|
||||
|
||||
// A visitor is refused everything a collection that does not admit visitors
|
||||
// offers, and everything that is not a record or a file: the schema, settings,
|
||||
// batch, realtime, aggregates and history.
|
||||
func TestAVisitorIsRefusedOutsideAVisitorsCollection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := newVisitorBase(t)
|
||||
p, c := b.person, b.comment
|
||||
|
||||
for _, r := range []struct{ method, target, body string }{
|
||||
{"GET", "/v1/collections/people/records", ""},
|
||||
{"GET", "/v1/collections/people/records/" + p, ""},
|
||||
{"POST", "/v1/collections/people/records", `{"name":"x"}`},
|
||||
{"PATCH", "/v1/collections/people/records/" + p, `{"name":"x"}`},
|
||||
{"DELETE", "/v1/collections/people/records/" + p, ""},
|
||||
{"GET", "/v1/files/people/" + p + "/doc.txt", ""},
|
||||
{"GET", "/v1/collections/lure/records", ""},
|
||||
{"GET", "/v1/collections/users/records", ""},
|
||||
{"GET", "/v1/collections", ""},
|
||||
{"GET", "/v1/collections/comments", ""},
|
||||
{"POST", "/v1/collections", `{"name":"mine","type":"base"}`},
|
||||
{"PATCH", "/v1/collections/comments", `{"listRule":null}`},
|
||||
{"GET", "/v1/settings", ""},
|
||||
{"POST", "/v1/batch", `{"requests":[{"method":"POST","url":"/v1/collections/people/records","body":{"name":"x"}}]}`},
|
||||
{"POST", "/v1/realtime/token", ""},
|
||||
{"POST", "/v1/realtime", `{"clientId":"x","subscriptions":["people"]}`},
|
||||
{"GET", "/v1/collections/comments/aggregate", ""},
|
||||
{"GET", "/v1/collections/comments/records/" + c + "/history", ""},
|
||||
{"GET", "/v1/nowhere", ""},
|
||||
} {
|
||||
if status, body := b.do(t, b.visitor, r.method, r.target, r.body); status != http.StatusForbidden {
|
||||
t.Errorf("%s %s: got %d %s, want 403", r.method, r.target, status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing above was written.
|
||||
if n, _ := b.app.CountRecords("people"); n != 1 {
|
||||
t.Fatalf("people holds %d records, want 1", n)
|
||||
}
|
||||
if _, err := b.app.FindCollectionByNameOrId("mine"); err == nil {
|
||||
t.Fatal("a visitor created a collection")
|
||||
}
|
||||
}
|
||||
|
||||
// On a collection that admits visitors, a visitor reads what anyone may, writes
|
||||
// as itself and changes only its own rows.
|
||||
func TestAVisitorWritesItsOwnRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := newVisitorBase(t)
|
||||
const records = "/v1/collections/comments/records"
|
||||
|
||||
status, body := b.do(t, b.visitor, "GET", records, "")
|
||||
if status != 200 || !strings.Contains(body, b.comment) {
|
||||
t.Fatalf("list: %d %s", status, body)
|
||||
}
|
||||
|
||||
// Another's name is refused, its own admitted.
|
||||
if status, body := b.do(t, b.visitor, "POST", records, `{"author":"`+memberID+`","body":"as you"}`); status != 400 {
|
||||
t.Fatalf("create as another: %d %s", status, body)
|
||||
}
|
||||
status, body = b.do(t, b.visitor, "POST", records, `{"author":"`+visitorID+`","body":"hello"}`)
|
||||
if status != 200 {
|
||||
t.Fatalf("create: %d %s", status, body)
|
||||
}
|
||||
var made struct{ Id string }
|
||||
if err := json.Unmarshal([]byte(body), &made); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Its own row it edits, and cannot hand to someone else.
|
||||
if status, body := b.do(t, b.visitor, "PATCH", records+"/"+made.Id, `{"body":"edited"}`); status != 200 || !strings.Contains(body, "edited") {
|
||||
t.Fatalf("update own: %d %s", status, body)
|
||||
}
|
||||
if status, body := b.do(t, b.visitor, "PATCH", records+"/"+made.Id, `{"author":"`+memberID+`"}`); status != 400 {
|
||||
t.Fatalf("give own away: %d %s", status, body)
|
||||
}
|
||||
|
||||
// Another's row it neither edits nor deletes.
|
||||
if status, body := b.do(t, b.visitor, "PATCH", records+"/"+b.comment, `{"body":"mine now"}`); status != 404 {
|
||||
t.Fatalf("update another's: %d %s", status, body)
|
||||
}
|
||||
if status, body := b.do(t, b.visitor, "PATCH", records+"/"+b.comment, `{"author":"`+visitorID+`"}`); status != 404 {
|
||||
t.Fatalf("take another's: %d %s", status, body)
|
||||
}
|
||||
if status, body := b.do(t, b.visitor, "DELETE", records+"/"+b.comment, ""); status != 404 {
|
||||
t.Fatalf("delete another's: %d %s", status, body)
|
||||
}
|
||||
if got, err := b.app.FindRecordById("comments", b.comment); err != nil || got.GetString("body") != "first" || got.GetString("author") != memberID {
|
||||
t.Fatalf("another's row changed: %v %v", got, err)
|
||||
}
|
||||
|
||||
// A file on the collection is past the door (there is none, so 404).
|
||||
if status, body := b.do(t, b.visitor, "GET", "/v1/files/comments/"+b.comment+"/doc.txt", ""); status != 404 {
|
||||
t.Fatalf("file: %d %s", status, body)
|
||||
}
|
||||
|
||||
if status, body := b.do(t, b.visitor, "DELETE", records+"/"+made.Id, ""); status != 204 {
|
||||
t.Fatalf("delete own: %d %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// A visitor reaches no row of another collection through one that admits it: a
|
||||
// relation it expands or filters on is held to that collection's rules as a
|
||||
// guest's would be. A member's request is unchanged.
|
||||
func TestAVisitorIsAGuestToEveryOtherCollection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := newVisitorBase(t)
|
||||
expand := "/v1/collections/comments/records?expand=ref"
|
||||
filter := "/v1/collections/comments/records?filter=" + url.QueryEscape(`ref.name = "Acme Corp"`)
|
||||
|
||||
if _, body := b.do(t, b.member, "GET", expand, ""); !strings.Contains(body, "Acme Corp") {
|
||||
t.Fatalf("member expand: %s", body)
|
||||
}
|
||||
if status, body := b.do(t, b.visitor, "GET", expand, ""); status != 200 || strings.Contains(body, "Acme Corp") {
|
||||
t.Fatalf("visitor expand: %d %s", status, body)
|
||||
}
|
||||
if _, body := b.do(t, b.member, "GET", filter, ""); !strings.Contains(body, `"totalItems":1`) {
|
||||
t.Fatalf("member filter: %s", body)
|
||||
}
|
||||
if status, body := b.do(t, b.visitor, "GET", filter, ""); status != 200 || !strings.Contains(body, `"totalItems":0`) {
|
||||
t.Fatalf("visitor filter: %d %s", status, body)
|
||||
}
|
||||
|
||||
// Nor is a visitor's relation to another collection's row allowed to
|
||||
// stand in for reading it.
|
||||
if status, body := b.do(t, b.member, "GET", "/v1/collections/people/records", ""); status != 200 || !strings.Contains(body, `"totalItems":1`) {
|
||||
t.Fatalf("member people: %d %s", status, body)
|
||||
}
|
||||
if status, body := b.do(t, b.member, "POST", "/v1/collections/comments/records", `{"author":"`+memberID+`","body":"member"}`); status != 200 {
|
||||
t.Fatalf("member create: %d %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Rules read a visitor as one: its role here is visitor, it administers
|
||||
// nothing here whatever it is at home, and its org is its own.
|
||||
func TestRulesReadAVisitor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, s := range []struct {
|
||||
rule string
|
||||
want string
|
||||
}{
|
||||
{`@request.auth.role = "visitor"`, `"totalItems":1`},
|
||||
{`@request.auth.admin = true`, `"totalItems":0`},
|
||||
{`@request.auth.role = "owner"`, `"totalItems":0`},
|
||||
{`@request.auth.org_id = "zoo"`, `"totalItems":1`},
|
||||
{`@request.auth.name = "Vic"`, `"totalItems":1`},
|
||||
{`@request.auth.id = "` + visitorID + `"`, `"totalItems":1`},
|
||||
} {
|
||||
b := newVisitorBase(t)
|
||||
c, err := b.app.FindCollectionByNameOrId("comments")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.ListRule = types.Pointer(s.rule)
|
||||
if err := b.app.Save(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status, body := b.do(t, b.visitor, "GET", "/v1/collections/comments/records", ""); status != 200 || !strings.Contains(body, s.want) {
|
||||
t.Errorf("%s: %d %s", s.rule, status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A SuperAdmin is never a visitor: it acts in a tenant's Base by naming it.
|
||||
func TestASuperAdminIsNoVisitor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := newVisitorBase(t)
|
||||
records := "/v1/collections/comments/records"
|
||||
|
||||
if status, body := b.do(t, b.admin, "GET", records, ""); status != 403 {
|
||||
t.Fatalf("unnamed: %d %s", status, body)
|
||||
}
|
||||
if status, body := b.do(t, b.admin, "DELETE", records+"/"+b.comment, "", "X-Org-Id", "acme"); status != 204 {
|
||||
t.Fatalf("named: %d %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Naming the Base's org buys a visitor nothing: the org is the token's.
|
||||
func TestAVisitorCannotNameTheBasesOrg(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := newVisitorBase(t)
|
||||
if status, body := b.do(t, b.visitor, "GET", "/v1/collections/people/records", "", "X-Org-Id", "acme"); status != 403 {
|
||||
t.Fatalf("%d %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// A visitor's writes are held to ten a minute, whether or not the deployment
|
||||
// limits anything.
|
||||
func TestAVisitorsWritesAreLimited(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := newVisitorBase(t)
|
||||
if b.app.Settings().RateLimits.Enabled {
|
||||
t.Fatal("the fixture limits rates; this test is about a Base that does not")
|
||||
}
|
||||
|
||||
for i := range 10 {
|
||||
if status, body := b.do(t, b.visitor, "POST", "/v1/collections/comments/records", `{"author":"`+visitorID+`","body":"again"}`); status != 200 {
|
||||
t.Fatalf("write %d: %d %s", i+1, status, body)
|
||||
}
|
||||
}
|
||||
if status, body := b.do(t, b.visitor, "POST", "/v1/collections/comments/records", `{"author":"`+visitorID+`","body":"again"}`); status != 429 {
|
||||
t.Fatalf("write 11: %d %s", status, body)
|
||||
}
|
||||
// Reads are not writes, and a member is not a visitor.
|
||||
if status, _ := b.do(t, b.visitor, "GET", "/v1/collections/comments/records", ""); status != 200 {
|
||||
t.Fatalf("read after the limit: %d", status)
|
||||
}
|
||||
if status, _ := b.do(t, b.member, "POST", "/v1/collections/comments/records", `{"author":"`+memberID+`","body":"member"}`); status != 200 {
|
||||
t.Fatalf("member after the limit: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// A Base whose auth collection cannot say a caller is a visitor admits none.
|
||||
func TestNoRoleNoVisitor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := newVisitorBase(t)
|
||||
users, err := b.app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users.Fields.RemoveByName(core.FieldNameAuthRole)
|
||||
if err := b.app.SaveNoValidate(users); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if status, body := b.do(t, b.visitor, "GET", "/v1/collections/comments/records", ""); status != 403 {
|
||||
t.Fatalf("%d %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// The collection's own option is what admits visitors, and it is set through
|
||||
// the collections API like any other.
|
||||
func TestTheOptionOpensACollection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := newVisitorBase(t)
|
||||
if status, _ := b.do(t, b.visitor, "GET", "/v1/collections/lure/records", ""); status != 403 {
|
||||
t.Fatalf("closed: %d", status)
|
||||
}
|
||||
if status, body := b.do(t, b.admin, "PATCH", "/v1/collections/lure", `{"visitors":true}`, "X-Org-Id", "acme"); status != 200 || !strings.Contains(body, `"visitors":true`) {
|
||||
t.Fatalf("open: %d %s", status, body)
|
||||
}
|
||||
if status, body := b.do(t, b.visitor, "GET", "/v1/collections/lure/records", ""); status != 200 {
|
||||
t.Fatalf("opened: %d %s", status, body)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,13 @@ type collectionBaseOptions struct {
|
||||
// collection asks for it.
|
||||
History bool `form:"history" json:"history,omitempty"`
|
||||
|
||||
// Visitors admits a visitor to the collection: a person IAM vouched for
|
||||
// whose token acts in another org than the one whose Base this is. Off,
|
||||
// which is every collection until one asks, a visitor is refused here as
|
||||
// anywhere else. On, its rules decide, reading the visitor as
|
||||
// `@request.auth.role = "visitor"`. See [Collection.AdmitsVisitors].
|
||||
Visitors bool `form:"visitors" json:"visitors,omitempty"`
|
||||
|
||||
// Label is what a person calls one record ("Person"), Plural what they
|
||||
// call many ("People"). Empty means the collection's name.
|
||||
Label string `form:"label" json:"label,omitempty"`
|
||||
@@ -27,6 +34,12 @@ type collectionBaseOptions struct {
|
||||
Description string `form:"description" json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// AdmitsVisitors reports whether a visitor may reach the collection's records
|
||||
// and files. Only a base collection can, and only when it says so.
|
||||
func (c *Collection) AdmitsVisitors() bool {
|
||||
return c != nil && c.IsBase() && c.Visitors
|
||||
}
|
||||
|
||||
func (o *collectionBaseOptions) validate(cv *collectionValidator) error {
|
||||
return validation.ValidateStruct(o,
|
||||
validation.Field(&o.Label, validation.Length(0, 255)),
|
||||
|
||||
@@ -34,6 +34,16 @@ const (
|
||||
FieldNameAuthRole = "role"
|
||||
)
|
||||
|
||||
// AuthRoleVisitor is the role of a visitor: a caller IAM vouched for whose
|
||||
// token acts in another org than the one whose Base serves it.
|
||||
const AuthRoleVisitor = "visitor"
|
||||
|
||||
// IsVisitor reports whether auth is a visitor's. Only a record minted for one
|
||||
// request from an IAM token is (apis resolveJWKSToken); a saved one never is.
|
||||
func IsVisitor(auth *Record) bool {
|
||||
return auth != nil && auth.IsNew() && auth.GetString(FieldNameAuthRole) == AuthRoleVisitor
|
||||
}
|
||||
|
||||
// Collections Base reads by name.
|
||||
const (
|
||||
// CollectionNameHistory holds a row for every write to a collection that
|
||||
|
||||
@@ -199,11 +199,18 @@ func (r *runner) processCollectionField() (*search.ResolverResult, error) {
|
||||
}
|
||||
|
||||
func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
|
||||
if r.resolver.requestInfo == nil || r.resolver.requestInfo.Auth == nil || r.resolver.requestInfo.Auth.Collection() == nil {
|
||||
if r.resolver.requestInfo == nil || r.resolver.requestInfo.Auth == nil || r.resolver.requestInfo.Auth.Collection() == nil ||
|
||||
(IsVisitor(r.resolver.requestInfo.Auth) && !r.resolver.baseCollection.AdmitsVisitors()) {
|
||||
// Nobody is signed in, so a comparison against this identity matches no
|
||||
// row — which is the whole point of `owner = @request.auth.id` and what
|
||||
// Postgres answers, where `owner = NULL` is NULL and NULL is not true.
|
||||
//
|
||||
// A visitor is nobody too, wherever the rule is of a collection that does
|
||||
// not admit visitors: one it joins through a relation, expands, or names
|
||||
// with @collection. The routes refuse a visitor such a collection
|
||||
// outright, and this keeps a rule another collection states from seeing
|
||||
// one as anything more than a guest.
|
||||
//
|
||||
// The identifier alone cannot say that: `NULL` reads as an EMPTY
|
||||
// identifier to the filter compiler, and equality against an empty
|
||||
// identifier is rewritten so that a missing value can still be compared.
|
||||
|
||||
+24
-1
@@ -74,7 +74,7 @@ saved:
|
||||
| `name` | `name`, else `displayName` |
|
||||
| `org_id` | the org the request acts in, when the auth collection has an `org_id` field |
|
||||
| `admin` | whether the caller administers that org (its IAM role there is owner or admin) |
|
||||
| `role` | the caller's role in that org: `owner`, `admin` or `member` |
|
||||
| `role` | the caller's role in that org: `owner`, `admin` or `member`, or `visitor` (below) |
|
||||
|
||||
Rules read these as `@request.auth.id`, `@request.auth.email` and so on, so
|
||||
"only the org's admins delete" is `@request.auth.admin = true`.
|
||||
@@ -97,6 +97,29 @@ never share a file, so a query in one cannot return another's rows. A token
|
||||
that belongs to several orgs picks one with `X-Org-Id: <org>`. Naming an org
|
||||
the token does not carry gets `403`, and so does a token with no org.
|
||||
|
||||
**Visitors.** A Base its host opened for one org (a site's) refuses a token
|
||||
that acts in another org, with `403`, everywhere except where a collection
|
||||
admits visitors. A base collection does with `"visitors": true`, which a
|
||||
superuser or the org's admin sets through `/v1/collections`. On such a
|
||||
collection a visitor may list, view, create, update and delete records and read
|
||||
their files, as its rules allow. Its auth record has `role` `visitor`, `admin`
|
||||
false, `org_id` its own org, and `id` and `name` as above, so a comment that
|
||||
is its writer's alone reads:
|
||||
|
||||
```
|
||||
createRule: @request.auth.id != "" && author = @request.auth.id
|
||||
updateRule: author = @request.auth.id
|
||||
deleteRule: author = @request.auth.id
|
||||
```
|
||||
|
||||
A visitor reaches nothing else: no other collection, not the schema, settings,
|
||||
batch, realtime, aggregates, history or a file token, and naming the Base's org
|
||||
in `X-Org-Id` is still `403`. Where a visitors' collection relates to another,
|
||||
that collection's rules read the visitor as a guest, so an expand or a filter
|
||||
through the relation shows only what a guest would see. A visitor's writes are
|
||||
held to ten a minute in a Base, counted by who it is. A superuser is never a
|
||||
visitor; it names the org.
|
||||
|
||||
**Keys** stand in for tokens when no person is signing in. IAM issues them.
|
||||
`sk-` keys are for servers. `pk-` keys are for web pages and reach only routes
|
||||
marked public, such as record lists and views.
|
||||
|
||||
Reference in New Issue
Block a user