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:
2026-10-20 18:10:41 -07:00
parent da4803a259
commit cebf25db06
8 changed files with 519 additions and 21 deletions
+9
View File
@@ -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
View File
@@ -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.
+20 -14
View File
@@ -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()))
}
+386
View File
@@ -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)
}
}
+13
View File
@@ -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)),
+10
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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.