Files
zeekayandHanzo Dev 63058856e8 a stream is opened with a grant, because a stream cannot carry a header
EventSource sends no headers, so the request that opens a stream arrives with
no credential and lands on the process's Base, while the request naming that
client carries one and lands on the caller's. The subscription then names a
client the broker has never heard of.

An authenticated request mints a grant and the stream spends it: once, on one
Base, within thirty seconds. The string stands for nothing on its own — what
it means is held here, as the Base the minting request resolved to and the
identity it resolved as, so a stream opens with exactly the authority of the
request that asked for it.

Not the caller's own token in the address. That token opens every service in
the estate for as long as it lives, and an address is written down by things
outside this process — an ingress, a gateway, a cache — where this one can
redact nothing. A grant is worth one stream for half a minute.

The rule is bound at the address a grant opens, so what one reaches is a
property of the route rather than of a string travelling in a URL. It sorts
after the header path, so it never competes with a credential that can travel
in one.

The socket surface beside it is deleted. Nothing referenced it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 17:09:34 -07:00

793 lines
22 KiB
Go

package apis
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/hook"
"github.com/hanzoai/base/tools/picker"
"github.com/hanzoai/base/tools/router"
"github.com/hanzoai/base/tools/routine"
"github.com/hanzoai/base/tools/search"
"github.com/hanzoai/base/tools/subscriptions"
"github.com/hanzoai/dbx"
"golang.org/x/sync/errgroup"
)
// note: the chunk size is arbitrary chosen and may change in the future
const clientsChunkSize = 150
// RealtimeClientAuthKey is the name of the realtime client store key that holds its auth state.
const RealtimeClientAuthKey = "auth"
// A record is broadcast on the Base it was written to, and a subscriber is
// registered on the broker of the Base its request resolved to — which for a
// caller in an org is that org's Base. So the broadcast hooks belong to every
// Base rather than to the one the router is built on.
func init() {
core.AppBindings.Register(bindRealtimeEvents)
}
// bindRealtimeApi registers the realtime api endpoints.
//
// The stream is opened with a grant because EventSource sends no headers; the
// other two carry the caller's own credential the ordinary way. See [grant].
func bindRealtimeApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
sub := rg.Group("/realtime")
sub.GET("", realtimeConnect).Bind(SkipSuccessActivityLog(), spendGrant())
sub.POST("", realtimeSetSubscriptions)
sub.POST("/token", realtimeGrant).Bind(RequireAuth())
}
func realtimeConnect(e *core.RequestEvent) error {
// disable global write deadline for the SSE connection
rc := http.NewResponseController(e.Response)
writeDeadlineErr := rc.SetWriteDeadline(time.Time{})
if writeDeadlineErr != nil {
if !errors.Is(writeDeadlineErr, http.ErrNotSupported) {
return e.InternalServerError("Failed to initialize SSE connection.", writeDeadlineErr)
}
// only log since there are valid cases where it may not be implement (e.g. httptest.ResponseRecorder)
e.App.Logger().Warn("SetWriteDeadline is not supported, fallback to the default server WriteTimeout")
}
// create cancellable request
cancelCtx, cancelRequest := context.WithCancel(e.Request.Context())
defer cancelRequest()
e.Request = e.Request.Clone(cancelCtx)
e.Response.Header().Set("Content-Type", "text/event-stream")
e.Response.Header().Set("Cache-Control", "no-store")
// https://github.com/hanzoai/base/discussions/480#discussioncomment-3657640
// https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering
e.Response.Header().Set("X-Accel-Buffering", "no")
connectEvent := new(core.RealtimeConnectRequestEvent)
connectEvent.RequestEvent = e
connectEvent.Client = subscriptions.NewDefaultClient()
connectEvent.IdleTimeout = 5 * time.Minute
return e.App.OnRealtimeConnectRequest().Trigger(connectEvent, func(ce *core.RealtimeConnectRequestEvent) error {
// register new subscription client
ce.App.SubscriptionsBroker().Register(ce.Client)
defer func() {
e.App.SubscriptionsBroker().Unregister(ce.Client.Id())
}()
ce.App.Logger().Debug("Realtime connection established.", "clientId", ce.Client.Id())
// signalize established connection (aka. fire "connect" message)
connectData := []byte(`{"clientId":"` + ce.Client.Id() + `"}`)
connectMsgEvent := new(core.RealtimeMessageEvent)
connectMsgEvent.RequestEvent = ce.RequestEvent
connectMsgEvent.Client = ce.Client
connectMsgEvent.Message = &subscriptions.Message{
Name: "CONNECT",
Data: connectData,
}
connectMsgErr := ce.App.OnRealtimeMessageSend().Trigger(connectMsgEvent, func(me *core.RealtimeMessageEvent) error {
err := me.Message.WriteSSE(me.Response, me.Client.Id())
if err != nil {
return err
}
return me.Flush()
})
if connectMsgErr != nil {
ce.App.Logger().Debug(
"Realtime connection closed (failed to deliver CONNECT)",
"clientId", ce.Client.Id(),
"error", connectMsgErr.Error(),
)
return nil
}
// start an idle timer to keep track of inactive/forgotten connections
idleTimer := time.NewTimer(ce.IdleTimeout)
defer idleTimer.Stop()
for {
select {
case <-idleTimer.C:
cancelRequest()
case msg, ok := <-ce.Client.Channel():
if !ok {
// channel is closed
ce.App.Logger().Debug(
"Realtime connection closed (closed channel)",
"clientId", ce.Client.Id(),
)
return nil
}
msgEvent := new(core.RealtimeMessageEvent)
msgEvent.RequestEvent = ce.RequestEvent
msgEvent.Client = ce.Client
msgEvent.Message = &msg
msgErr := ce.App.OnRealtimeMessageSend().Trigger(msgEvent, func(me *core.RealtimeMessageEvent) error {
err := me.Message.WriteSSE(me.Response, me.Client.Id())
if err != nil {
return err
}
return me.Flush()
})
if msgErr != nil {
ce.App.Logger().Debug(
"Realtime connection closed (failed to deliver message)",
"clientId", ce.Client.Id(),
"error", msgErr.Error(),
)
return nil
}
idleTimer.Stop()
idleTimer.Reset(ce.IdleTimeout)
case <-ce.Request.Context().Done():
// connection is closed
ce.App.Logger().Debug(
"Realtime connection closed (cancelled request)",
"clientId", ce.Client.Id(),
)
return nil
}
}
})
}
type realtimeSubscribeForm struct {
ClientId string `form:"clientId" json:"clientId"`
Subscriptions []string `form:"subscriptions" json:"subscriptions"`
}
func (form *realtimeSubscribeForm) validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.ClientId, validation.Required, validation.Length(1, 255)),
validation.Field(&form.Subscriptions,
validation.Length(0, 1000),
validation.Each(validation.Length(0, 2500)),
),
)
}
// note: in case of reconnect, clients will have to resubmit all subscriptions again
func realtimeSetSubscriptions(e *core.RequestEvent) error {
form := new(realtimeSubscribeForm)
err := e.BindBody(form)
if err != nil {
return e.BadRequestError("", err)
}
err = form.validate()
if err != nil {
return e.BadRequestError("", err)
}
// find subscription client
client, err := e.App.SubscriptionsBroker().ClientById(form.ClientId)
if err != nil {
return e.NotFoundError("Missing or invalid client id.", err)
}
// for now allow only guest->auth upgrades and any other auth change is forbidden
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
if clientAuth != nil && !isSameAuth(clientAuth, e.Auth) {
return e.ForbiddenError("The current and the previous request authorization don't match.", nil)
}
event := new(core.RealtimeSubscribeRequestEvent)
event.RequestEvent = e
event.Client = client
event.Subscriptions = form.Subscriptions
return e.App.OnRealtimeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeRequestEvent) error {
// update auth state
e.Client.Set(RealtimeClientAuthKey, e.Auth)
// unsubscribe from any previous existing subscriptions
e.Client.Unsubscribe()
// subscribe to the new subscriptions
e.Client.Subscribe(e.Subscriptions...)
e.App.Logger().Debug(
"Realtime subscriptions updated.",
"clientId", e.Client.Id(),
"subscriptions", e.Subscriptions,
)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
// updateClientsAuth updates the existing clients auth record with the new one (matched by ID).
func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
group := new(errgroup.Group)
for _, chunk := range chunks {
group.Go(func() error {
for _, client := range chunk {
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
if clientAuth != nil &&
clientAuth.Id == newAuthRecord.Id &&
clientAuth.Collection().Name == newAuthRecord.Collection().Name {
client.Set(RealtimeClientAuthKey, newAuthRecord)
}
}
return nil
})
}
return group.Wait()
}
// realtimeUnsetClientsAuthState unsets the auth state of all clients that have the provided auth model.
func realtimeUnsetClientsAuthState(app core.App, authModel core.Model) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
group := new(errgroup.Group)
for _, chunk := range chunks {
group.Go(func() error {
for _, client := range chunk {
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
if clientAuth != nil &&
clientAuth.Id == authModel.PK() &&
clientAuth.Collection().Name == authModel.TableName() {
client.Unset(RealtimeClientAuthKey)
}
}
return nil
})
}
return group.Wait()
}
func bindRealtimeEvents(app core.App) {
// update the clients that has auth record association
app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*core.ModelEvent]{
Func: func(e *core.ModelEvent) error {
authRecord := realtimeResolveRecord(e.App, e.Model, core.CollectionTypeAuth)
if authRecord != nil {
if err := realtimeUpdateClientsAuth(e.App, authRecord); err != nil {
app.Logger().Warn(
"Failed to update client(s) associated to the updated auth record",
"id", authRecord.Id,
"collectionName", authRecord.Collection().Name,
"error", err.Error(),
)
}
}
return e.Next()
},
Priority: -99,
})
// remove the client(s) associated to the deleted auth model
// (note: works also with custom model for legacy)
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*core.ModelEvent]{
Func: func(e *core.ModelEvent) error {
collection := realtimeResolveRecordCollection(e.App, e.Model)
if collection != nil && collection.IsAuth() {
if err := realtimeUnsetClientsAuthState(e.App, e.Model); err != nil {
app.Logger().Warn(
"Failed to remove client(s) associated to the deleted auth model",
"id", e.Model.PK(),
"collectionName", e.Model.TableName(),
"error", err.Error(),
)
}
}
return e.Next()
},
Priority: -99,
})
app.OnModelAfterCreateSuccess().Bind(&hook.Handler[*core.ModelEvent]{
Func: func(e *core.ModelEvent) error {
record := realtimeResolveRecord(e.App, e.Model, "")
if record != nil {
err := realtimeBroadcastRecord(e.App, "create", record, false)
if err != nil {
app.Logger().Debug(
"Failed to broadcast record create",
"id", record.Id,
"collectionName", record.Collection().Name,
"error", err.Error(),
)
}
}
return e.Next()
},
Priority: -99,
})
app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*core.ModelEvent]{
Func: func(e *core.ModelEvent) error {
record := realtimeResolveRecord(e.App, e.Model, "")
if record != nil {
err := realtimeBroadcastRecord(e.App, "update", record, false)
if err != nil {
app.Logger().Debug(
"Failed to broadcast record update",
"id", record.Id,
"collectionName", record.Collection().Name,
"error", err.Error(),
)
}
}
return e.Next()
},
Priority: -99,
})
// delete: dry cache
app.OnModelDelete().Bind(&hook.Handler[*core.ModelEvent]{
Func: func(e *core.ModelEvent) error {
record := realtimeResolveRecord(e.App, e.Model, "")
if record != nil {
// note: use the outside scoped app instance for the access checks so that the API rules
// are performed out of the delete transaction ensuring that they would still work even if
// a cascade-deleted record's API rule relies on an already deleted parent record
err := realtimeBroadcastRecord(e.App, "delete", record, true, app)
if err != nil {
app.Logger().Debug(
"Failed to dry cache record delete",
"id", record.Id,
"collectionName", record.Collection().Name,
"error", err.Error(),
)
}
}
return e.Next()
},
Priority: 99, // execute as later as possible
})
// delete: broadcast
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*core.ModelEvent]{
Func: func(e *core.ModelEvent) error {
// note: only ensure that it is a collection record
// and don't use realtimeResolveRecord because in case of a
// custom model it'll fail to resolve since the record is already deleted
collection := realtimeResolveRecordCollection(e.App, e.Model)
if collection != nil {
err := realtimeBroadcastDryCacheKey(e.App, getDryCacheKey("delete", e.Model))
if err != nil {
app.Logger().Debug(
"Failed to broadcast record delete",
"id", e.Model.PK(),
"collectionName", collection.Name,
"error", err.Error(),
)
}
}
return e.Next()
},
Priority: -99,
})
// delete: failure
app.OnModelAfterDeleteError().Bind(&hook.Handler[*core.ModelErrorEvent]{
Func: func(e *core.ModelErrorEvent) error {
record := realtimeResolveRecord(e.App, e.Model, "")
if record != nil {
err := realtimeUnsetDryCacheKey(e.App, getDryCacheKey("delete", record))
if err != nil {
app.Logger().Debug(
"Failed to cleanup after broadcast record delete failure",
"id", record.Id,
"collectionName", record.Collection().Name,
"error", err.Error(),
)
}
}
return e.Next()
},
Priority: -99,
})
}
// resolveRecord converts *if possible* the provided model interface to a Record.
// This is usually helpful if the provided model is a custom Record model struct.
func realtimeResolveRecord(app core.App, model core.Model, optCollectionType string) *core.Record {
var record *core.Record
switch m := model.(type) {
case *core.Record:
record = m
case core.RecordProxy:
record = m.ProxyRecord()
}
if record != nil {
if optCollectionType == "" || record.Collection().Type == optCollectionType {
return record
}
return nil
}
tblName := model.TableName()
// skip Log model checks
if tblName == core.LogsTableName {
return nil
}
// check if it is custom Record model struct
collection, _ := app.FindCachedCollectionByNameOrId(tblName)
if collection != nil && (optCollectionType == "" || collection.Type == optCollectionType) {
if id, ok := model.PK().(string); ok {
record, _ = app.FindRecordById(collection, id)
}
}
return record
}
// realtimeResolveRecordCollection extracts *if possible* the Collection model from the provided model interface.
// This is usually helpful if the provided model is a custom Record model struct.
func realtimeResolveRecordCollection(app core.App, model core.Model) (collection *core.Collection) {
switch m := model.(type) {
case *core.Record:
return m.Collection()
case core.RecordProxy:
return m.ProxyRecord().Collection()
default:
// check if it is custom Record model struct
collection, err := app.FindCachedCollectionByNameOrId(model.TableName())
if err == nil {
return collection
}
}
return nil
}
// recordData represents the broadcasted record subscrition message data.
type recordData struct {
Record any `json:"record"` /* map or core.Record */
Action string `json:"action"`
}
// Note: the optAccessCheckApp is there in case you want the access check
// to be performed against different db app context (e.g. out of a transaction).
// If set, it is expected that optAccessCheckApp instance is used for read-only operations to avoid deadlocks.
// If not set, it fallbacks to app.
func realtimeBroadcastRecord(app core.App, action string, record *core.Record, dryCache bool, optAccessCheckApp ...core.App) error {
collection := record.Collection()
if collection == nil {
return errors.New("[broadcastRecord] Record collection not set")
}
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
if len(chunks) == 0 {
return nil // no subscribers
}
subscriptionRuleMap := map[string]*string{
(collection.Name + "/" + record.Id + "?"): collection.ViewRule,
(collection.Id + "/" + record.Id + "?"): collection.ViewRule,
(collection.Name + "/*?"): collection.ListRule,
(collection.Id + "/*?"): collection.ListRule,
// @deprecated: the same as the wildcard topic but kept for legacy
(collection.Name + "?"): collection.ListRule,
(collection.Id + "?"): collection.ListRule,
}
dryCacheKey := getDryCacheKey(action, record)
group := new(errgroup.Group)
accessCheckApp := app
if len(optAccessCheckApp) > 0 {
accessCheckApp = optAccessCheckApp[0]
}
for _, chunk := range chunks {
group.Go(func() error {
var clientAuth *core.Record
for _, client := range chunk {
// note: not executed concurrently to avoid races and to ensure
// that the access checks are applied for the current record db state
for prefix, rule := range subscriptionRuleMap {
subs := client.Subscriptions(prefix)
if len(subs) == 0 {
continue
}
clientAuth, _ = client.Get(RealtimeClientAuthKey).(*core.Record)
for sub, options := range subs {
// mock request data
requestInfo := &core.RequestInfo{
Context: core.RequestInfoContextRealtime,
Method: "GET",
Query: options.Query,
Headers: options.Headers,
Auth: clientAuth,
}
if !realtimeCanAccessRecord(accessCheckApp, record, requestInfo, rule) {
continue
}
// create a clean record copy without expand and unknown fields because we don't know yet
// which exact fields the client subscription requested or has permissions to access
cleanRecord := record.Fresh()
// trigger the enrich hooks
enrichErr := triggerRecordEnrichHooks(app, requestInfo, []*core.Record{cleanRecord}, func() error {
// apply expand
rawExpand := options.Query[expandQueryParam]
if rawExpand != "" {
expandErrs := app.ExpandRecord(cleanRecord, strings.Split(rawExpand, ","), expandFetch(app, requestInfo))
if len(expandErrs) > 0 {
app.Logger().Debug(
"[broadcastRecord] expand errors",
"id", cleanRecord.Id,
"collectionName", cleanRecord.Collection().Name,
"sub", sub,
"expand", rawExpand,
"errors", expandErrs,
)
}
}
// ignore the auth record email visibility checks
// for auth owner, superuser or manager
if collection.IsAuth() {
if isSameAuth(clientAuth, cleanRecord) ||
realtimeCanAccessRecord(accessCheckApp, cleanRecord, requestInfo, collection.ManageRule) {
cleanRecord.IgnoreEmailVisibility(true)
}
}
return nil
})
if enrichErr != nil {
app.Logger().Debug(
"[broadcastRecord] record enrich error",
"id", cleanRecord.Id,
"collectionName", cleanRecord.Collection().Name,
"sub", sub,
"error", enrichErr,
)
continue
}
data := &recordData{
Action: action,
Record: cleanRecord,
}
// check fields
rawFields := options.Query[fieldsQueryParam]
if rawFields != "" {
decoded, err := picker.Pick(cleanRecord, rawFields)
if err == nil {
data.Record = decoded
} else {
app.Logger().Debug(
"[broadcastRecord] pick fields error",
"id", cleanRecord.Id,
"collectionName", cleanRecord.Collection().Name,
"sub", sub,
"fields", rawFields,
"error", err.Error(),
)
}
}
dataBytes, err := json.Marshal(data)
if err != nil {
app.Logger().Debug(
"[broadcastRecord] data marshal error",
"id", cleanRecord.Id,
"collectionName", cleanRecord.Collection().Name,
"error", err.Error(),
)
continue
}
msg := subscriptions.Message{
Name: sub,
Data: dataBytes,
}
if dryCache {
messages, ok := client.Get(dryCacheKey).([]subscriptions.Message)
if !ok {
messages = []subscriptions.Message{msg}
} else {
messages = append(messages, msg)
}
client.Set(dryCacheKey, messages)
} else {
routine.FireAndForget(func() {
client.Send(msg)
})
}
}
}
}
return nil
})
}
return group.Wait()
}
// realtimeBroadcastDryCacheKey broadcasts the dry cached key related messages.
func realtimeBroadcastDryCacheKey(app core.App, key string) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
if len(chunks) == 0 {
return nil // no subscribers
}
group := new(errgroup.Group)
for _, chunk := range chunks {
group.Go(func() error {
for _, client := range chunk {
messages, ok := client.Get(key).([]subscriptions.Message)
if !ok {
continue
}
client.Unset(key)
client := client
routine.FireAndForget(func() {
for _, msg := range messages {
client.Send(msg)
}
})
}
return nil
})
}
return group.Wait()
}
// realtimeUnsetDryCacheKey removes the dry cached key related messages.
func realtimeUnsetDryCacheKey(app core.App, key string) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
if len(chunks) == 0 {
return nil // no subscribers
}
group := new(errgroup.Group)
for _, chunk := range chunks {
group.Go(func() error {
for _, client := range chunk {
if client.Get(key) != nil {
client.Unset(key)
}
}
return nil
})
}
return group.Wait()
}
func getDryCacheKey(action string, model core.Model) string {
pkStr, ok := model.PK().(string)
if !ok {
pkStr = fmt.Sprintf("%v", model.PK())
}
return action + "/" + model.TableName() + "/" + pkStr
}
func isSameAuth(authA, authB *core.Record) bool {
if authA == nil {
return authB == nil
}
if authB == nil {
return false
}
return authA.Id == authB.Id && authA.Collection().Id == authB.Collection().Id
}
// realtimeCanAccessRecord checks if the subscription client has access to the specified record model.
func realtimeCanAccessRecord(
app core.App,
record *core.Record,
requestInfo *core.RequestInfo,
accessRule *string,
) bool {
// check the access rule
// ---
if ok, _ := app.CanAccessRecord(record, requestInfo, accessRule); !ok {
return false
}
// check the subscription client-side filter (if any)
// ---
filter := requestInfo.Query[search.FilterQueryParam]
if filter == "" {
return true // no further checks needed
}
err := checkForSuperuserOnlyRuleFields(requestInfo)
if err != nil {
return false
}
var exists int
q := app.ConcurrentDB().Select("(1)").
From(record.Collection().Name).
AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
resolver := core.NewRecordFieldResolver(app, record.Collection(), requestInfo, false)
expr, err := search.FilterData(filter).BuildExpr(resolver)
if err != nil {
return false
}
q.AndWhere(expr)
err = resolver.UpdateQuery(q)
if err != nil {
return false
}
err = q.Limit(1).Row(&exists)
return err == nil && exists > 0
}