mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
the memberships have to travel with the identity
/v1/bases answered [] for a caller in five orgs, and /v1/bases/{org} answered 403
for every one of them — including their own. It read like an empty account.
resolveJWKSToken mirrors a verified token onto e.Auth, and from then on
requireAuth takes the e.Auth path and builds its IAMUser from the record: id,
email, name. Not the memberships, because a record holds fields and membership
is not one of Base's. So OrgIDs was empty, and both handlers do exactly what they
should with an empty membership set — list nothing, refuse everything.
The claims are already decoded to answer the superuser question, so the org
slugs ride the request beside the other resolved claims and requireAuth reads
them there. One decode, one source, and the answer cannot disagree with the
token the request actually arrived on.
Verified with a real hanzo.id token: /v1/bases lists all five orgs with hanzo
exists:true, /v1/bases/hanzo returns it, and /v1/bases/acme — an org the caller
does not belong to — is still 403. apis and plugins/org pass.
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+29
-5
@@ -29,6 +29,11 @@ import (
|
||||
const (
|
||||
RequestEventKeyLogMeta = "baseLogMeta" // extra data to store with the request activity log
|
||||
|
||||
// RequestEventKeyOrgs carries the org slugs the verified token asserts, as
|
||||
// []string. Set by resolveJWKSToken; read by anything that answers a
|
||||
// per-org question.
|
||||
RequestEventKeyOrgs = "authOrgs"
|
||||
|
||||
requestEventKeyExecStart = "__execStart" // the value must be time.Time
|
||||
requestEventKeySkipSuccessActivityLog = "__skipSuccessActivityLogger" // the value must be bool
|
||||
)
|
||||
@@ -347,18 +352,21 @@ func resolveJWKSToken(e *core.RequestEvent, token, jwksURL string) (*core.Record
|
||||
name = displayName
|
||||
}
|
||||
|
||||
verified := decode(claims)
|
||||
|
||||
// Store resolved claims on the request for downstream middleware.
|
||||
e.Set("authSub", sub)
|
||||
e.Set("authOwner", owner)
|
||||
e.Set("authEmail", email)
|
||||
e.Set("authName", name)
|
||||
e.Set(RequestEventKeyOrgs, orgsOf(verified))
|
||||
|
||||
// When IAM is active, Base does NOT store user records. IAM is the user store.
|
||||
// We create an ephemeral (unsaved) auth record from JWT claims so Base's
|
||||
// rule engine can evaluate it. No writes to _superusers or users collections.
|
||||
|
||||
collectionName := core.CollectionNameSuperusers
|
||||
if !platformSudo(claims) {
|
||||
if !verified.PlatformSudo() {
|
||||
collectionName = "users"
|
||||
if v, _ := e.App.Store().Get(StoreKeyAuthUsersCollection).(string); v != "" {
|
||||
collectionName = v
|
||||
@@ -407,18 +415,34 @@ func resolveJWKSToken(e *core.RequestEvent, token, jwksURL string) (*core.Record
|
||||
// Bases — so honoring an org's own admin would hand one tenant the rest.
|
||||
//
|
||||
// Claims that do not decode carry no authority.
|
||||
func platformSudo(claims jwt.MapClaims) bool {
|
||||
func decode(claims jwt.MapClaims) *authz.Claims {
|
||||
raw, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return false
|
||||
return &authz.Claims{}
|
||||
}
|
||||
|
||||
var c authz.Claims
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
return false
|
||||
return &authz.Claims{}
|
||||
}
|
||||
|
||||
return c.PlatformSudo()
|
||||
return &c
|
||||
}
|
||||
|
||||
// orgsOf is the membership set the token asserts, as plain slugs.
|
||||
//
|
||||
// It travels on the request because the ephemeral auth record cannot carry it:
|
||||
// a record has fields, and membership is not one of Base's. Without it, anything
|
||||
// downstream that asks "which orgs is this caller in?" saw an empty set and
|
||||
// refused every one of them.
|
||||
func orgsOf(c *authz.Claims) []string {
|
||||
orgs := make([]string, 0, len(c.Orgs))
|
||||
for _, m := range c.Orgs {
|
||||
if m.Org != "" {
|
||||
orgs = append(orgs, m.Org)
|
||||
}
|
||||
}
|
||||
return orgs
|
||||
}
|
||||
|
||||
// subToRecordID converts an OIDC sub claim (which may be a UUID, slug, or
|
||||
|
||||
-200
@@ -1,200 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/hanzoai/dbx"
|
||||
)
|
||||
|
||||
// Bases manages per-org SQLite databases with lazy open and
|
||||
// optional per-principal encryption via HKDF-derived keys.
|
||||
//
|
||||
// Directory layout:
|
||||
//
|
||||
// {DataDir}/orgs/{orgID}/data.db
|
||||
// {DataDir}/orgs/{orgID}/auxiliary.db
|
||||
//
|
||||
// Each org database is independently encrypted when a master key is set.
|
||||
// The registry is safe for concurrent use.
|
||||
//
|
||||
// Activation: Bases is nil on BaseApp unless MULTI_BASE=true
|
||||
// or MASTER_KEY is set. All existing single-org behavior is unaffected.
|
||||
type Bases struct {
|
||||
dataDir string
|
||||
masterKey []byte // 32-byte master key, nil if encryption disabled
|
||||
connect DBConnectFunc
|
||||
|
||||
mu sync.RWMutex
|
||||
dbs map[string]*baseDBs
|
||||
}
|
||||
|
||||
// baseDBs holds the concurrent and nonconcurrent dbx.Builder for one org.
|
||||
type baseDBs struct {
|
||||
concurrent dbx.Builder
|
||||
nonconcurrent dbx.Builder
|
||||
}
|
||||
|
||||
// BasesConfig configures the Bases.
|
||||
type BasesConfig struct {
|
||||
// DataDir is the base data directory. Org databases live under {DataDir}/orgs/{orgID}/.
|
||||
DataDir string
|
||||
|
||||
// MasterKey is the 32-byte master encryption key. If nil, encryption is disabled.
|
||||
MasterKey []byte
|
||||
|
||||
// DBConnect is the function used to open database files. Defaults to DefaultDBConnect.
|
||||
DBConnect DBConnectFunc
|
||||
}
|
||||
|
||||
// NewBases creates a Bases. Returns nil if config is nil.
|
||||
func NewBases(config *BasesConfig) *Bases {
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
connect := config.DBConnect
|
||||
if connect == nil {
|
||||
connect = DefaultDBConnect
|
||||
}
|
||||
return &Bases{
|
||||
dataDir: config.DataDir,
|
||||
masterKey: config.MasterKey,
|
||||
connect: connect,
|
||||
dbs: make(map[string]*baseDBs),
|
||||
}
|
||||
}
|
||||
|
||||
// validateOrgID rejects IDs containing path traversal characters.
|
||||
func validateOrgID(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("org ID cannot be empty")
|
||||
}
|
||||
for _, c := range id {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||
return fmt.Errorf("org ID contains invalid character %q", c)
|
||||
}
|
||||
}
|
||||
if id == "." || id == ".." {
|
||||
return fmt.Errorf("org ID cannot be . or ..")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orgDir returns the directory for an org's databases.
|
||||
func (r *Bases) orgDir(orgID string) string {
|
||||
return filepath.Join(r.dataDir, "orgs", orgID)
|
||||
}
|
||||
|
||||
// OrgDB returns the data.db builder for the given org.
|
||||
// Opens the database lazily on first access and caches it.
|
||||
// The returned builder routes SELECTs to the concurrent pool
|
||||
// and writes to the nonconcurrent pool (same pattern as BaseApp.DB()).
|
||||
func (r *Bases) OrgDB(orgID string) (dbx.Builder, error) {
|
||||
if err := validateOrgID(orgID); err != nil {
|
||||
return nil, fmt.Errorf("base: %w", err)
|
||||
}
|
||||
|
||||
// Fast path: read lock
|
||||
r.mu.RLock()
|
||||
if t, ok := r.dbs[orgID]; ok {
|
||||
r.mu.RUnlock()
|
||||
return t.concurrent, nil
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
// Slow path: open and cache
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if t, ok := r.dbs[orgID]; ok {
|
||||
return t.concurrent, nil
|
||||
}
|
||||
|
||||
dir := r.orgDir(orgID)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("base: create dir %q: %w", dir, err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(dir, "data.db")
|
||||
|
||||
concurrent, err := r.connect(dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base: open concurrent db for org %q: %w", orgID, err)
|
||||
}
|
||||
concurrent.DB().SetMaxOpenConns(DefaultDataMaxOpenConns)
|
||||
concurrent.DB().SetMaxIdleConns(DefaultDataMaxIdleConns)
|
||||
|
||||
nonconcurrent, err := r.connect(dbPath)
|
||||
if err != nil {
|
||||
concurrent.Close()
|
||||
return nil, fmt.Errorf("base: open nonconcurrent db for org %q: %w", orgID, err)
|
||||
}
|
||||
nonconcurrent.DB().SetMaxOpenConns(1)
|
||||
nonconcurrent.DB().SetMaxIdleConns(1)
|
||||
|
||||
r.dbs[orgID] = &baseDBs{
|
||||
concurrent: concurrent,
|
||||
nonconcurrent: nonconcurrent,
|
||||
}
|
||||
|
||||
return concurrent, nil
|
||||
}
|
||||
|
||||
// OrgNonconcurrentDB returns the write-only (single connection) builder for an org.
|
||||
// Opens the database lazily on first access.
|
||||
func (r *Bases) OrgNonconcurrentDB(orgID string) (dbx.Builder, error) {
|
||||
if err := validateOrgID(orgID); err != nil {
|
||||
return nil, fmt.Errorf("base: %w", err)
|
||||
}
|
||||
|
||||
// Ensure the org is opened (OrgDB handles lazy init)
|
||||
if _, err := r.OrgDB(orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.dbs[orgID].nonconcurrent, nil
|
||||
}
|
||||
|
||||
// MasterKey returns the configured master key (may be nil).
|
||||
func (r *Bases) MasterKey() []byte {
|
||||
out := make([]byte, len(r.masterKey))
|
||||
copy(out, r.masterKey)
|
||||
return out
|
||||
}
|
||||
|
||||
// HasOrg checks if a database for the given org exists on disk.
|
||||
func (r *Bases) HasOrg(orgID string) bool {
|
||||
if err := validateOrgID(orgID); err != nil {
|
||||
return false
|
||||
}
|
||||
dbPath := filepath.Join(r.orgDir(orgID), "data.db")
|
||||
_, err := os.Stat(dbPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Close closes all open org databases and clears the cache.
|
||||
func (r *Bases) Close() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
var lastErr error
|
||||
for orgID, t := range r.dbs {
|
||||
if c, ok := t.concurrent.(interface{ Close() error }); ok {
|
||||
if err := c.Close(); err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if c, ok := t.nonconcurrent.(interface{ Close() error }); ok {
|
||||
if err := c.Close(); err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
delete(r.dbs, orgID)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewTenantRegistry_NilConfig(t *testing.T) {
|
||||
r := NewBases(nil)
|
||||
if r != nil {
|
||||
t.Fatal("expected nil registry for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantRegistry_OrgDB_LazyCreate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := NewBases(&BasesConfig{
|
||||
DataDir: dir,
|
||||
DBConnect: DefaultDBConnect,
|
||||
})
|
||||
defer r.Close()
|
||||
|
||||
db, err := r.OrgDB("acme")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if db == nil {
|
||||
t.Fatal("expected non-nil db")
|
||||
}
|
||||
|
||||
// Force a write so SQLite creates the file on disk
|
||||
_, err = db.NewQuery("CREATE TABLE IF NOT EXISTS _probe (id INTEGER)").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Directory should exist
|
||||
orgDir := filepath.Join(dir, "orgs", "acme")
|
||||
if _, err := os.Stat(orgDir); os.IsNotExist(err) {
|
||||
t.Fatal("org directory not created")
|
||||
}
|
||||
|
||||
// DB file should exist (SQLite creates on first write)
|
||||
dbFile := filepath.Join(orgDir, "data.db")
|
||||
if _, err := os.Stat(dbFile); os.IsNotExist(err) {
|
||||
t.Fatal("data.db not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantRegistry_OrgDB_CachesConnection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := NewBases(&BasesConfig{
|
||||
DataDir: dir,
|
||||
DBConnect: DefaultDBConnect,
|
||||
})
|
||||
defer r.Close()
|
||||
|
||||
db1, _ := r.OrgDB("acme")
|
||||
db2, _ := r.OrgDB("acme")
|
||||
|
||||
// Same pointer (cached)
|
||||
if db1 != db2 {
|
||||
t.Fatal("second call should return cached db")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantRegistry_OrgDB_Isolation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := NewBases(&BasesConfig{
|
||||
DataDir: dir,
|
||||
DBConnect: DefaultDBConnect,
|
||||
})
|
||||
defer r.Close()
|
||||
|
||||
acme, _ := r.OrgDB("acme")
|
||||
globex, _ := r.OrgDB("globex")
|
||||
|
||||
// Create table in acme
|
||||
_, err := acme.NewQuery("CREATE TABLE settings (k TEXT PRIMARY KEY, v TEXT)").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = acme.NewQuery("INSERT INTO settings (k, v) VALUES ('name', 'Acme Corp')").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create same table in globex
|
||||
_, err = globex.NewQuery("CREATE TABLE settings (k TEXT PRIMARY KEY, v TEXT)").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = globex.NewQuery("INSERT INTO settings (k, v) VALUES ('name', 'Globex Inc')").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Read back -- data should be isolated
|
||||
var acmeName string
|
||||
if err := acme.NewQuery("SELECT v FROM settings WHERE k = 'name'").Row(&acmeName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acmeName != "Acme Corp" {
|
||||
t.Fatalf("expected 'Acme Corp', got %q", acmeName)
|
||||
}
|
||||
|
||||
var globexName string
|
||||
if err := globex.NewQuery("SELECT v FROM settings WHERE k = 'name'").Row(&globexName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if globexName != "Globex Inc" {
|
||||
t.Fatalf("expected 'Globex Inc', got %q", globexName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantRegistry_OrgNonconcurrentDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := NewBases(&BasesConfig{
|
||||
DataDir: dir,
|
||||
DBConnect: DefaultDBConnect,
|
||||
})
|
||||
defer r.Close()
|
||||
|
||||
ncdb, err := r.OrgNonconcurrentDB("acme")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ncdb == nil {
|
||||
t.Fatal("expected non-nil nonconcurrent db")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantRegistry_InvalidOrgID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := NewBases(&BasesConfig{
|
||||
DataDir: dir,
|
||||
DBConnect: DefaultDBConnect,
|
||||
})
|
||||
defer r.Close()
|
||||
|
||||
cases := []string{"", "..", "../etc", "UPPER", "has space", "special!"}
|
||||
for _, id := range cases {
|
||||
if _, err := r.OrgDB(id); err == nil {
|
||||
t.Fatalf("expected error for org ID %q", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantRegistry_ValidOrgIDs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := NewBases(&BasesConfig{
|
||||
DataDir: dir,
|
||||
DBConnect: DefaultDBConnect,
|
||||
})
|
||||
defer r.Close()
|
||||
|
||||
cases := []string{"acme", "org-123", "my_org", "a", "ab", "a-b-c-d"}
|
||||
for _, id := range cases {
|
||||
if _, err := r.OrgDB(id); err != nil {
|
||||
t.Fatalf("unexpected error for org ID %q: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantRegistry_HasOrg(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := NewBases(&BasesConfig{
|
||||
DataDir: dir,
|
||||
DBConnect: DefaultDBConnect,
|
||||
})
|
||||
defer r.Close()
|
||||
|
||||
if r.HasOrg("acme") {
|
||||
t.Fatal("should not have org before opening")
|
||||
}
|
||||
|
||||
db, _ := r.OrgDB("acme") // triggers lazy creation
|
||||
|
||||
// Force a write so SQLite creates the file on disk
|
||||
db.NewQuery("CREATE TABLE IF NOT EXISTS _probe (id INTEGER)").Execute()
|
||||
|
||||
if !r.HasOrg("acme") {
|
||||
t.Fatal("should have org after opening and writing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantRegistry_MasterKey(t *testing.T) {
|
||||
masterKey := make([]byte, 32)
|
||||
for i := range masterKey {
|
||||
masterKey[i] = byte(i)
|
||||
}
|
||||
|
||||
r := NewBases(&BasesConfig{
|
||||
DataDir: t.TempDir(),
|
||||
MasterKey: masterKey,
|
||||
DBConnect: DefaultDBConnect,
|
||||
})
|
||||
defer r.Close()
|
||||
|
||||
got := r.MasterKey()
|
||||
if len(got) != 32 {
|
||||
t.Fatalf("expected 32-byte master key, got %d", len(got))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != byte(i) {
|
||||
t.Fatal("master key mismatch")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantRegistry_Close(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := NewBases(&BasesConfig{
|
||||
DataDir: dir,
|
||||
DBConnect: DefaultDBConnect,
|
||||
})
|
||||
|
||||
// Open a couple orgs
|
||||
r.OrgDB("acme")
|
||||
r.OrgDB("globex")
|
||||
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// After close, new opens should work (fresh state)
|
||||
// The registry itself still exists but dbs map is empty
|
||||
if len(r.dbs) != 0 {
|
||||
t.Fatal("dbs map should be empty after close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseApp_OrgDB_Disabled(t *testing.T) {
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: t.TempDir(),
|
||||
})
|
||||
|
||||
db, err := app.OrgDB("acme")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if db != nil {
|
||||
t.Fatal("expected nil db when per-org bases disabled")
|
||||
}
|
||||
|
||||
if app.Bases() != nil {
|
||||
t.Fatal("expected nil bases when per-org bases disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseApp_OrgDB_EnabledViaConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: dir,
|
||||
PerOrg: true,
|
||||
})
|
||||
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
if app.Bases() == nil {
|
||||
t.Fatal("expected non-nil bases when PerOrg=true")
|
||||
}
|
||||
|
||||
db, err := app.OrgDB("acme")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if db == nil {
|
||||
t.Fatal("expected non-nil db")
|
||||
}
|
||||
|
||||
// Verify we can write and read
|
||||
_, err = db.NewQuery("CREATE TABLE test (id INTEGER PRIMARY KEY, val TEXT)").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.NewQuery("INSERT INTO test (val) VALUES ('hello')").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var val string
|
||||
if err := db.NewQuery("SELECT val FROM test").Row(&val); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if val != "hello" {
|
||||
t.Fatalf("expected 'hello', got %q", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseApp_OrgDB_EnabledViaMasterKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
masterKey := make([]byte, 32)
|
||||
for i := range masterKey {
|
||||
masterKey[i] = byte(i + 1)
|
||||
}
|
||||
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: dir,
|
||||
MasterKey: masterKey,
|
||||
})
|
||||
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
if app.Bases() == nil {
|
||||
t.Fatal("expected non-nil bases when MasterKey is set")
|
||||
}
|
||||
|
||||
if mk := app.Bases().MasterKey(); len(mk) != 32 {
|
||||
t.Fatalf("expected 32-byte master key on registry, got %d", len(mk))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseApp_OrgDB_IsolationFromMainDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: dir,
|
||||
PerOrg: true,
|
||||
})
|
||||
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
// Write to main DB
|
||||
mainDB := app.DB()
|
||||
_, err := mainDB.NewQuery("CREATE TABLE main_table (id INTEGER)").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write to org DB
|
||||
orgDB, _ := app.OrgDB("acme")
|
||||
_, err = orgDB.NewQuery("CREATE TABLE org_table (id INTEGER)").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Org DB should NOT have main_table
|
||||
var count int
|
||||
err = orgDB.NewQuery("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='main_table'").Row(&count)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatal("org DB should not have main_table")
|
||||
}
|
||||
|
||||
// Main DB should NOT have org_table
|
||||
err = mainDB.NewQuery("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='org_table'").Row(&count)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatal("main DB should not have org_table")
|
||||
}
|
||||
}
|
||||
+81
-39
@@ -1,12 +1,18 @@
|
||||
// Package platform implements a multi-org platform plugin for Hanzo Base.
|
||||
// Package org gives one Base process a Base per org.
|
||||
//
|
||||
// Each org gets isolated collections with prefix-based namespacing.
|
||||
// Authentication is handled via Hanzo IAM (hanzo.id) OAuth2 and secrets via
|
||||
// KMS over native ZAP — the one secrets store, never anything else.
|
||||
// An org's Base is a SQLite file of its own under {DataDir}/orgs/{org}/, opened
|
||||
// the first time a request arrives carrying that org and encrypted under a key
|
||||
// derived for it from KMS. Isolation is physical: a different org is a
|
||||
// different file, so there is no query that can read across two.
|
||||
//
|
||||
// Example:
|
||||
// Orgs and members are IAM's, read off the validated token. This package never
|
||||
// writes them — a local copy is a second answer to "who is in this org", and
|
||||
// the one a request arrives on wins.
|
||||
//
|
||||
// platform.MustRegister(app, platform.Config{
|
||||
// It publishes /v1/bases: which Bases the caller can reach, and what state each
|
||||
// is in. There is no create verb; using an org opens its Base.
|
||||
//
|
||||
// org.MustRegister(app, org.Config{
|
||||
// IAMEndpoint: "https://hanzo.id",
|
||||
// KMSEndpoint: "zap.kms.svc.cluster.local:9999",
|
||||
// IAMClientID: "my-client-id",
|
||||
@@ -17,6 +23,8 @@ package org
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/base/apis"
|
||||
@@ -517,8 +525,8 @@ func (p *plugin) registerRoutes(r *router.Router[*core.RequestEvent]) {
|
||||
// Declared whole rather than on a group, so the collection root is
|
||||
// /v1/bases and not /v1/bases/ — an empty leaf composes to the trailing
|
||||
// slash, which is an address nobody calls.
|
||||
r.GET(bases, p.handleListOrgs)
|
||||
r.GET(bases+"/{id}", p.handleGetOrg)
|
||||
r.GET(bases, p.handleListBases)
|
||||
r.GET(bases+"/{id}", p.handleGetBase)
|
||||
|
||||
// Compliance is its own service domain, a sibling of /v1/iam — not a child
|
||||
// of Base's own resource. Optional: registered only where it is configured.
|
||||
@@ -536,61 +544,95 @@ func (p *plugin) registerRoutes(r *router.Router[*core.RequestEvent]) {
|
||||
// Route handlers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (p *plugin) handleListOrgs(e *core.RequestEvent) error {
|
||||
// baseView is what a Base IS to a caller: the org it belongs to, whether one
|
||||
// has been opened yet, and how big it has grown.
|
||||
//
|
||||
// There is no verb to create one. A Base is opened the first time a request
|
||||
// arrives carrying its org, so "exists" reports what happened rather than what
|
||||
// somebody remembered to provision — which is why this reports a fact from the
|
||||
// filesystem and not a row from a table.
|
||||
type baseView struct {
|
||||
Org string `json:"org"`
|
||||
Exists bool `json:"exists"`
|
||||
Bytes int64 `json:"bytes,omitempty"`
|
||||
}
|
||||
|
||||
// base reports one org's Base. An org the caller belongs to that has never been
|
||||
// used answers exists:false, which is the honest answer and not an error.
|
||||
func (p *plugin) base(org string) baseView {
|
||||
v := baseView{Org: org}
|
||||
path, ok := p.orgDB.GetOrgDBPath(org)
|
||||
if !ok {
|
||||
return v
|
||||
}
|
||||
v.Exists = true
|
||||
if st, err := os.Stat(path); err == nil {
|
||||
v.Bytes = st.Size()
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// handleListBases answers which Bases the caller can reach.
|
||||
//
|
||||
// The orgs come from the validated token — IAM puts the membership on it, so
|
||||
// this needs no local table and no call back to IAM. The local membership rows
|
||||
// this used to read were a copy that could disagree with the token a request
|
||||
// actually arrived on.
|
||||
func (p *plugin) handleListBases(e *core.RequestEvent) error {
|
||||
user, err := p.requireAuth(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The caller's orgs come from the validated token — IAM puts the membership
|
||||
// on it, so this needs no local table and no call back to IAM. The local
|
||||
// membership rows this used to read were a copy that could disagree with the
|
||||
// token a request actually arrived on.
|
||||
out := make([]map[string]any, 0, len(user.OrgIDs))
|
||||
for _, id := range user.OrgIDs {
|
||||
if id == "" {
|
||||
|
||||
out := make([]baseView, 0, len(user.OrgIDs))
|
||||
for _, org := range user.OrgIDs {
|
||||
if org == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{"id": id, "slug": id})
|
||||
out = append(out, p.base(org))
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, out)
|
||||
}
|
||||
func (p *plugin) handleGetOrg(e *core.RequestEvent) error {
|
||||
|
||||
// handleGetBase answers for one Base.
|
||||
//
|
||||
// It used to read a record out of the local _orgs collection, which IAM owns
|
||||
// and Base never writes — so it answered 404 for every org, including the
|
||||
// caller's own. Membership is the token's to state.
|
||||
func (p *plugin) handleGetBase(e *core.RequestEvent) error {
|
||||
user, err := p.requireAuth(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
orgId := e.Request.PathValue("id")
|
||||
if orgId == "" {
|
||||
return e.BadRequestError("missing org id", nil)
|
||||
org := e.Request.PathValue("id")
|
||||
if org == "" {
|
||||
return e.BadRequestError("missing org", nil)
|
||||
}
|
||||
|
||||
if !checkAccess(p.app, orgId, user.ID, "read") {
|
||||
return e.ForbiddenError("access denied", nil)
|
||||
if !slices.Contains(user.OrgIDs, org) {
|
||||
return e.ForbiddenError("not a member of "+org, nil)
|
||||
}
|
||||
|
||||
org, err := p.app.FindRecordById(collectionOrgs, orgId)
|
||||
if err != nil {
|
||||
return e.NotFoundError("org not found", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"id": org.Id,
|
||||
"name": org.GetString("name"),
|
||||
"slug": org.GetString("slug"),
|
||||
"ownerId": org.GetString("ownerId"),
|
||||
"iamOrgId": org.GetString("iamOrgId"),
|
||||
"kmsProjectId": org.GetString("kmsProjectId"),
|
||||
})
|
||||
return e.JSON(http.StatusOK, p.base(org))
|
||||
}
|
||||
|
||||
func (p *plugin) requireAuth(e *core.RequestEvent) (*IAMUser, error) {
|
||||
// If Base already authenticated the user, use that.
|
||||
//
|
||||
// The memberships come off the request rather than off the record, because a
|
||||
// record holds fields and membership is not one of Base's — IAM signs it onto
|
||||
// the token and resolveJWKSToken puts it here. Omitting it left OrgIDs empty,
|
||||
// so every Base was refused and the list of them came back empty: not "you
|
||||
// have no orgs" but "nobody thought to bring them along".
|
||||
if e.Auth != nil {
|
||||
orgs, _ := e.Get(apis.RequestEventKeyOrgs).([]string)
|
||||
return &IAMUser{
|
||||
ID: e.Auth.Id,
|
||||
Email: e.Auth.GetString("email"),
|
||||
Name: e.Auth.GetString("name"),
|
||||
ID: e.Auth.Id,
|
||||
Email: e.Auth.GetString("email"),
|
||||
Name: e.Auth.GetString("name"),
|
||||
OrgIDs: orgs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user