mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Filter params were pasted in one at a time, so a value spelling another param's placeholder was rewritten inside its own quotes and the second value's quotes closed the first one's string: a bound value could add conditions to the filter. They are now substituted in one pass over the filter as written. An org admin administers, and a member reads the schema of, only a Base opened for its org (StoreKeyTenant): a Base deployed for no org belongs to whoever deployed it. The org plugin names the org of every Base it opens. A saved user's record never carries org authority: @request.auth.admin and @request.auth.role answer only for the record a verified token mints, and the two fields are system and hidden. The emptiness figures of an aggregate are columns of its one query, so a grouped request counts the groups it shows and runs one query however many figures it asks for. A stream the host routed to its org's Base is served by that live Base, not the instance its grant was minted on.
244 lines
6.7 KiB
Go
244 lines
6.7 KiB
Go
package org
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"sync"
|
|
|
|
"github.com/hanzoai/authz"
|
|
"github.com/hanzoai/base/apis"
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/dbx"
|
|
"github.com/hanzoai/sqlite"
|
|
)
|
|
|
|
// encrypted returns how to open an org's Base with the org's own key, or nil to
|
|
// open it plaintext.
|
|
//
|
|
// The key is derived per org (OrgDEK), so one org's file is unreadable with
|
|
// another's key and a leaked key is worth one tenant rather than all of them.
|
|
// sqlite.OpenDB is the whole mechanism and it means the same thing under both
|
|
// builds: SQLCipher when Base is linked with cgo, and the pure-Go codec envelope
|
|
// otherwise. The two are byte-compatible, so a file written by one opens under
|
|
// the other — which matters because CI ships CGO_ENABLED=0 and an operator may
|
|
// not.
|
|
//
|
|
// An empty DEK is dev mode: no master key was configured, and the Base opens
|
|
// plaintext exactly as it did before. A master key of the wrong length is an
|
|
// error rather than a silent downgrade, because a key that quietly becomes no
|
|
// key is how data ends up in the clear while the deployment believes otherwise.
|
|
// For the same reason a file that is already plaintext is refused under a key.
|
|
func encryptedConnect(orgDB *OrgDB, org string) (core.DBConnectFunc, error) {
|
|
dek, err := orgDB.OrgDEK(org)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if dek == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
key, err := hex.DecodeString(dek)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("platform: decode the %q key: %w", org, err)
|
|
}
|
|
|
|
return func(dbPath string) (*dbx.DB, error) {
|
|
// Same refusal DefaultDBConnect makes: a build whose SQLite cannot
|
|
// answer geoDistance dies here rather than at a failed search.
|
|
if err := core.VerifySQLiteMathFunctions(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := refusePlaintext(dbPath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
db, err := sqlite.OpenDB(dbPath, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return dbx.NewFromDB(db, "sqlite"), nil
|
|
}, nil
|
|
}
|
|
|
|
// sqliteHeader is how every unencrypted SQLite file starts. An encrypted file
|
|
// starts with its random salt.
|
|
var sqliteHeader = []byte("SQLite format 3\x00")
|
|
|
|
// refusePlaintext refuses a file that is already an unencrypted SQLite
|
|
// database. A missing or empty file is created encrypted.
|
|
func refusePlaintext(path string) error {
|
|
f, err := os.Open(path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
head := make([]byte, len(sqliteHeader))
|
|
if _, err := io.ReadFull(f, head); err == nil && bytes.Equal(head, sqliteHeader) {
|
|
return fmt.Errorf("platform: %s is an unencrypted SQLite file and a master key is configured", path)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// bases maps an org to the Base that serves it: {DataDir}/orgs/{org}, opened the
|
|
// first time a request arrives carrying that org. There is no create verb —
|
|
// using an org opens its Base.
|
|
//
|
|
// The reserved admin org's Base is the process's own. Schema, settings, backups
|
|
// and logs are process-wide and _superusers is unscoped inside the process, so a
|
|
// platform session belongs to the Base the process runs on rather than to some
|
|
// tenant's file. That Base is reached by naming the org, not by falling into it
|
|
// when nothing else matched — falling into it is what this replaces.
|
|
type bases struct {
|
|
p *plugin
|
|
|
|
mu sync.RWMutex
|
|
open map[string]*entry
|
|
}
|
|
|
|
// entry is one org's Base and the promise that it is open.
|
|
//
|
|
// The lock this registry needs is the one over its map. Opening a Base is a
|
|
// migration run on a fresh file, by far the longest thing here, so it happens
|
|
// outside that lock: the entry goes in first, the open follows, and everyone
|
|
// naming that org waits on ready while no other org waits at all.
|
|
type entry struct {
|
|
app core.App
|
|
err error
|
|
ready chan struct{}
|
|
}
|
|
|
|
func newBases(p *plugin) *bases {
|
|
return &bases{p: p, open: make(map[string]*entry)}
|
|
}
|
|
|
|
// base opens the Base that serves org, and is the one lookup that does.
|
|
func (b *bases) base(org string) (core.App, error) {
|
|
if org == authz.AdminOrg {
|
|
return b.p.app, nil
|
|
}
|
|
if err := validateSlug(org); err != nil {
|
|
return nil, fmt.Errorf("org %q: %w", org, err)
|
|
}
|
|
|
|
e, mine := b.claim(org)
|
|
if mine {
|
|
b.fill(org, e)
|
|
}
|
|
|
|
<-e.ready
|
|
if e.err != nil {
|
|
return nil, e.err
|
|
}
|
|
return e.app, nil
|
|
}
|
|
|
|
// claim finds org's entry or puts a fresh one in, and reports whether this
|
|
// caller is the one that put it there and so owes it an open. Everyone else
|
|
// waits on the entry, which is what keeps one org's first request off every
|
|
// other org's path.
|
|
func (b *bases) claim(org string) (*entry, bool) {
|
|
b.mu.RLock()
|
|
e, ok := b.open[org]
|
|
b.mu.RUnlock()
|
|
if ok {
|
|
return e, false
|
|
}
|
|
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if e, ok := b.open[org]; ok { // put there while we swapped the lock
|
|
return e, false
|
|
}
|
|
e = &entry{ready: make(chan struct{})}
|
|
b.open[org] = e
|
|
|
|
return e, true
|
|
}
|
|
|
|
// fill opens org's Base and publishes the outcome to whoever is waiting on it.
|
|
//
|
|
// ready is never closed without one: err is set before the work so an open that
|
|
// unwinds still answers. An entry that did not end in a Base is dropped, so the
|
|
// next request opens it afresh rather than being told the same thing for the
|
|
// life of the process.
|
|
func (b *bases) fill(org string, e *entry) {
|
|
e.err = fmt.Errorf("open the Base for %q stopped", org)
|
|
|
|
defer func() {
|
|
close(e.ready)
|
|
if e.err == nil {
|
|
return
|
|
}
|
|
b.mu.Lock()
|
|
if b.open[org] == e {
|
|
delete(b.open, org)
|
|
}
|
|
b.mu.Unlock()
|
|
}()
|
|
|
|
dir, err := b.p.orgDB.ProvisionOrg(org)
|
|
if err != nil {
|
|
e.err = err
|
|
return
|
|
}
|
|
|
|
connect, err := encryptedConnect(b.p.orgDB, org)
|
|
if err != nil {
|
|
e.err = err
|
|
return
|
|
}
|
|
|
|
app := core.NewBaseApp(core.BaseAppConfig{
|
|
DataDir: dir,
|
|
EncryptionEnv: b.p.app.EncryptionEnv(),
|
|
IsDev: b.p.app.IsDev(),
|
|
DBConnect: connect,
|
|
})
|
|
if err := app.Bootstrap(); err != nil {
|
|
e.err = fmt.Errorf("open the Base for %q: %w", org, err)
|
|
return
|
|
}
|
|
// the org's Base, and no other org's
|
|
app.Store().Set(apis.StoreKeyTenant, org)
|
|
b.p.declare(app)
|
|
|
|
e.app, e.err = app, nil
|
|
b.p.app.Logger().Info("base: opened", "org", org, "dir", dir)
|
|
}
|
|
|
|
// close releases every Base this process opened. A Base holds two SQLite
|
|
// handles, so leaving them to the exit means the last writes of a graceful
|
|
// shutdown land in a WAL nobody checkpoints.
|
|
//
|
|
// The entries come out of the map first: an open still in flight is waited for
|
|
// rather than skipped, and waiting under the lock would meet the open trying to
|
|
// take it.
|
|
func (b *bases) close() {
|
|
b.mu.Lock()
|
|
open := b.open
|
|
b.open = make(map[string]*entry)
|
|
b.mu.Unlock()
|
|
|
|
for org, e := range open {
|
|
<-e.ready
|
|
if e.app == nil {
|
|
continue
|
|
}
|
|
if err := e.app.ResetBootstrapState(); err != nil {
|
|
b.p.app.Logger().Error("base: failed to close", "org", org, "error", err)
|
|
}
|
|
}
|
|
}
|