Files
zeekay cebf25db06 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.
2026-10-20 18:10:41 -07:00

290 lines
9.3 KiB
Go

package core
import (
"context"
"database/sql/driver"
"regexp"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/hanzoai/base/core/validators"
"github.com/hanzoai/base/tools/list"
)
var fieldNameRegex = regexp.MustCompile(`^\w+$`)
const maxSafeJSONInt int64 = 1<<53 - 1
// Commonly used field names.
const (
FieldNameId = "id"
FieldNameCollectionId = "collectionId"
FieldNameCollectionName = "collectionName"
FieldNameExpand = "expand"
FieldNameEmail = "email"
FieldNameEmailVisibility = "emailVisibility"
FieldNameVerified = "verified"
FieldNameTokenKey = "tokenKey"
FieldNameSource = "source"
// FieldNameAuthAdmin and FieldNameAuthRole are the caller's authority in
// the org a request acts in, on the auth record IAM's token is mirrored
// into: whether it administers the org, and its role there.
FieldNameAuthAdmin = "admin"
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
// keeps its history.
CollectionNameHistory = "_history"
// CollectionNameSegments is where a saved filter lives: a record with the
// collection it filters (`object`) and the filter (`where`), which a where
// clause names with the inSegment and notInSegment operators.
CollectionNameSegments = "segments"
)
// SystemFields returns special internal field names that are usually readonly.
var SystemDynamicFieldNames = []string{
FieldNameCollectionId,
FieldNameCollectionName,
FieldNameExpand,
}
// Common RecordInterceptor action names.
const (
InterceptorActionValidate = "validate"
InterceptorActionDelete = "delete"
InterceptorActionDeleteExecute = "deleteExecute"
InterceptorActionAfterDelete = "afterDelete"
InterceptorActionAfterDeleteError = "afterDeleteError"
InterceptorActionCreate = "create"
InterceptorActionCreateExecute = "createExecute"
InterceptorActionAfterCreate = "afterCreate"
InterceptorActionAfterCreateError = "afterCreateFailure"
InterceptorActionUpdate = "update"
InterceptorActionUpdateExecute = "updateExecute"
InterceptorActionAfterUpdate = "afterUpdate"
InterceptorActionAfterUpdateError = "afterUpdateError"
)
// Common field errors.
var (
ErrUnknownField = validation.NewError("validation_unknown_field", "Unknown or invalid field.")
ErrInvalidFieldValue = validation.NewError("validation_invalid_field_value", "Invalid field value.")
ErrMustBeSystemAndHidden = validation.NewError("validation_must_be_system_and_hidden", `The field must be marked as "System" and "Hidden".`)
ErrMustBeSystem = validation.NewError("validation_must_be_system", `The field must be marked as "System".`)
)
// EmailGated reports whether field is an auth collection's email, which a
// caller who is not a superuser reads only on the records that made it public
// (emailVisibility). The filter resolver and the aggregate route both ask it, so
// an address a record keeps private is out of reach of a comparison and of a
// figure alike.
func EmailGated(collection *Collection, field Field) bool {
return collection != nil && collection.IsAuth() && field != nil && field.GetName() == FieldNameEmail
}
// FieldFactoryFunc defines a simple function to construct a specific Field instance.
type FieldFactoryFunc func() Field
// Fields holds all available collection fields.
var Fields = map[string]FieldFactoryFunc{}
// Field defines a common interface that all Collection fields should implement.
type Field interface {
// note: the getters has an explicit "Get" prefix to avoid conflicts with their related field members
// GetId returns the field id.
GetId() string
// SetId changes the field id.
SetId(id string)
// GetName returns the field name.
GetName() string
// SetName changes the field name.
SetName(name string)
// GetSystem returns the field system flag state.
GetSystem() bool
// SetSystem changes the field system flag state.
SetSystem(system bool)
// GetHidden returns the field hidden flag state.
GetHidden() bool
// SetHidden changes the field hidden flag state.
SetHidden(hidden bool)
// Type returns the unique type of the field.
Type() string
// ColumnType returns the DB column definition of the field.
ColumnType(app App) string
// PrepareValue returns a properly formatted field value based on the provided raw one.
//
// This method is also called on record construction to initialize its default field value.
PrepareValue(record *Record, raw any) (any, error)
// ValidateSettings validates the current field value associated with the provided record.
ValidateValue(ctx context.Context, app App, record *Record) error
// ValidateSettings validates the current field settings.
ValidateSettings(ctx context.Context, app App, collection *Collection) error
}
// MaxBodySizeCalculator defines an optional field interface for
// specifying the max size of a field value.
type MaxBodySizeCalculator interface {
// CalculateMaxBodySize returns the approximate max body size of a field value.
CalculateMaxBodySize() int64
}
type (
SetterFunc func(record *Record, raw any)
// SetterFinder defines a field interface for registering custom field value setters.
SetterFinder interface {
// FindSetter returns a single field value setter function
// by performing pattern-like field matching using the specified key.
//
// The key is usually just the field name but it could also
// contains "modifier" characters based on which you can perform custom set operations
// (ex. "users+" could be mapped to a function that will append new user to the existing field value).
//
// Return nil if you want to fallback to the default field value setter.
FindSetter(key string) SetterFunc
}
)
type (
GetterFunc func(record *Record) any
// GetterFinder defines a field interface for registering custom field value getters.
GetterFinder interface {
// FindGetter returns a single field value getter function
// by performing pattern-like field matching using the specified key.
//
// The key is usually just the field name but it could also
// contains "modifier" characters based on which you can perform custom get operations
// (ex. "description:excerpt" could be mapped to a function that will return an excerpt of the current field value).
//
// Return nil if you want to fallback to the default field value setter.
FindGetter(key string) GetterFunc
}
)
// DriverValuer defines a Field interface for exporting and formatting
// a field value for the database.
type DriverValuer interface {
// DriverValue exports a single field value for persistence in the database.
DriverValue(record *Record) (driver.Value, error)
}
// MultiValuer defines a field interface that every multi-valued (eg. with MaxSelect) field has.
type MultiValuer interface {
// IsMultiple checks whether the field is configured to support multiple or single values.
IsMultiple() bool
}
// RecordInterceptor defines a field interface for reacting to various
// Record related operations (create, delete, validate, etc.).
type RecordInterceptor interface {
// Interceptor is invoked when a specific record action occurs
// allowing you to perform extra validations and normalization
// (ex. uploading or deleting files).
//
// Note that users must call actionFunc() manually if they want to
// execute the specific record action.
Intercept(
ctx context.Context,
app App,
record *Record,
actionName string,
actionFunc func() error,
) error
}
// DefaultFieldIdValidationRule performs base validation on a field id value.
func DefaultFieldIdValidationRule(value any) error {
v, ok := value.(string)
if !ok {
return validators.ErrUnsupportedValueType
}
rules := []validation.Rule{
validation.Required,
validation.Length(1, 100),
}
for _, r := range rules {
if err := r.Validate(v); err != nil {
return err
}
}
return nil
}
// exclude special filter and system literals
var excludeNames = append([]any{
"null", "true", "false", "_rowid_",
}, list.ToInterfaceSlice(SystemDynamicFieldNames)...)
// DefaultFieldIdValidationRule performs base validation on a field name value.
func DefaultFieldNameValidationRule(value any) error {
v, ok := value.(string)
if !ok {
return validators.ErrUnsupportedValueType
}
rules := []validation.Rule{
validation.Required,
validation.Length(1, 100),
validation.Match(fieldNameRegex),
validation.NotIn(excludeNames...),
validation.By(checkForVia),
}
for _, r := range rules {
if err := r.Validate(v); err != nil {
return err
}
}
return nil
}
func checkForVia(value any) error {
v, _ := value.(string)
if v == "" {
return nil
}
if strings.Contains(strings.ToLower(v), "_via_") {
return validation.NewError("validation_found_via", `The value cannot contain "_via_".`)
}
return nil
}
func noopSetter(record *Record, raw any) {
// do nothing
}