Files
zeekayandHanzo Dev f5fd2fb3c8 one statement omits every nested store, so the next level is not forgotten
A backup checkpoints the write-ahead log of the Base it runs on. A store
nested under that Base — an org's, and a user's under an org's — is neither
quiesced nor named, so it was copied mid-write beside a log copied at another
instant. Excluding it removes a copy that could not be restored.

Both levels are stated once, on create and on restore together, where a Base
is opened. The two must name the same thing: an archive that omits a subtree
while restore does not spare it deletes that subtree. The zip says what it
leaves out.

The network document described a per-file key scheme, with an environment
table read by no code, as though it shipped. It says what the code does.

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

318 lines
9.2 KiB
Go

package org
import (
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/hanzoai/base/core"
"github.com/hanzoai/cek"
"github.com/hanzoai/namespace"
)
// OrgDB manages per-org AND per-user SQLite databases.
//
// Directory layout:
//
// {DataDir}/orgs/{orgSlug}/data.db ← the org's Base
// {DataDir}/orgs/{orgSlug}/users/{userId}/data.db ← per-user PII + keys
//
// An org's directory is the data dir of a Base, so the file in it is named the
// way every Base names its data file. It was org.db, which is one name too
// many: the directory already says whose it is, and the second name made the
// file look like something other than a Base.
//
// Each file gets its own key, derived by github.com/hanzoai/cek from the master
// key and the namespace naming whose data it is:
//
// org DEK = cek.DeriveKey(master, org/{orgSlug}, "org")
// user DEK = cek.DeriveKey(master, org/{orgSlug}/{userId}, "user")
//
// Zero data commingling — org data and user PII live in separate files under
// separate keys. TestOrgDB_DEK_IsCEKDerivation holds these two lines to the
// code, because a comment about a derivation cannot fail when the derivation
// drifts and this package has been wrong that way before.
type OrgDB struct {
app core.App
masterKey string
mu sync.RWMutex
dbs map[string]string // key → db path
}
func NewOrgDB(app core.App, masterKey string) *OrgDB {
return &OrgDB{
app: app,
masterKey: masterKey,
dbs: make(map[string]string),
}
}
// Where a store of its own sits under the data directory of the Base above it:
// the orgs' Bases under the platform's, a user's database under its org's.
//
// Both are named here because both are the same fact — see [omitNested], which
// is what keeps either of them out of the archive above it.
const (
orgsDirName = "orgs"
usersDirName = "users"
)
// OrgsDir returns the base directory for all org databases.
func (t *OrgDB) OrgsDir() string {
return filepath.Join(t.app.DataDir(), orgsDirName)
}
// validateSlug rejects slugs containing path traversal characters.
func validateSlug(s string) error {
if s == "" {
return fmt.Errorf("slug cannot be empty")
}
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return fmt.Errorf("slug contains invalid character %q", c)
}
}
if s == "." || s == ".." {
return fmt.Errorf("slug cannot be . or ..")
}
return nil
}
// --- Org-level database ---
// OrgDir returns the directory for an org. Validates slug to prevent path traversal.
func (t *OrgDB) OrgDir(orgSlug string) string {
if err := validateSlug(orgSlug); err != nil {
return filepath.Join(t.OrgsDir(), "_invalid")
}
return filepath.Join(t.OrgsDir(), orgSlug)
}
// OrgDBPath returns the org-level SQLite database path.
func (t *OrgDB) OrgDBPath(orgSlug string) string {
return filepath.Join(t.OrgDir(orgSlug), "data.db")
}
// The subsystem each key belongs to. One store is one subsystem, so these name
// what the file holds rather than who it is for — who is the namespace.
const (
orgDEKSubsystem = "org"
userDEKSubsystem = "user"
)
// dek derives ns's key for subsystem with github.com/hanzoai/cek, the one key
// derivation in the platform.
//
// An empty master key is dev mode: no key, and no encryption, so the caller
// gets "" and no error. A master key of any OTHER wrong length is a
// misconfiguration and is reported, because a key that quietly becomes no key
// is how data ends up in the clear while the deployment believes otherwise.
func (t *OrgDB) dek(ns namespace.Namespace, subsystem string) (string, error) {
if t.masterKey == "" {
return "", nil
}
key, err := cek.DeriveKey([]byte(t.masterKey), ns, subsystem)
if err != nil {
return "", fmt.Errorf("platform: derive %s key: %w", subsystem, err)
}
return hex.EncodeToString(key), nil
}
// OrgDEK derives the org-level data encryption key.
func (t *OrgDB) OrgDEK(orgSlug string) (string, error) {
ns, err := namespace.Org(orgSlug)
if err != nil {
return "", err
}
return t.dek(ns, orgDEKSubsystem)
}
// ProvisionOrg creates an org's directory and returns it. What goes in it is
// the Base's business — see [bases.base], which opens one there.
//
// It deliberately does not record the org as having a database. It used to,
// which meant an org counted as provisioned the moment a directory existed and
// /v1/bases reported a Base that was not there yet.
func (t *OrgDB) ProvisionOrg(orgSlug string) (string, error) {
if err := validateSlug(orgSlug); err != nil {
return "", fmt.Errorf("invalid org slug: %w", err)
}
dir := t.OrgDir(orgSlug)
if err := os.MkdirAll(dir, 0700); err != nil {
return "", fmt.Errorf("create org dir %q: %w", dir, err)
}
// Create users subdirectory
if err := os.MkdirAll(filepath.Join(dir, usersDirName), 0700); err != nil {
return "", fmt.Errorf("create org users dir: %w", err)
}
return dir, nil
}
// --- Per-user database ---
// UserDir returns the directory for a specific user within an org.
func (t *OrgDB) UserDir(orgSlug, userId string) string {
if err := validateSlug(userId); err != nil {
return filepath.Join(t.OrgDir(orgSlug), usersDirName, "_invalid")
}
return filepath.Join(t.OrgDir(orgSlug), usersDirName, userId)
}
// UserDBPath returns the per-user SQLite database path.
func (t *OrgDB) UserDBPath(orgSlug, userId string) string {
return filepath.Join(t.UserDir(orgSlug, userId), "data.db")
}
// UserDEK derives the per-user data encryption key. User PII gets its own key,
// separate from the org's.
//
// The org rides in the namespace alongside the user, so acme/alice and
// globex/alice stay different keys even where two orgs use the same user id.
//
// namespace.Of is the door here, rather than OrgProject, because these ids are
// already slugs by this package's own contract — validateSlug, and the org's
// directory is named with the raw slug. Sanitizing here and not there would key
// a file by one rendering of a name and place it by another; erroring instead
// keeps the key and the path reading the same string.
func (t *OrgDB) UserDEK(orgSlug, userId string) (string, error) {
ns, err := namespace.Of(orgSlug + "/" + userId)
if err != nil {
return "", err
}
return t.dek(ns, userDEKSubsystem)
}
// ProvisionUser creates the per-user directory and database.
func (t *OrgDB) ProvisionUser(orgSlug, userId string) (string, error) {
if err := validateSlug(orgSlug); err != nil {
return "", fmt.Errorf("invalid org slug: %w", err)
}
if err := validateSlug(userId); err != nil {
return "", fmt.Errorf("invalid user id: %w", err)
}
dir := t.UserDir(orgSlug, userId)
if err := os.MkdirAll(dir, 0700); err != nil {
return "", fmt.Errorf("create user dir %q: %w", dir, err)
}
dbPath := t.UserDBPath(orgSlug, userId)
t.mu.Lock()
t.dbs["user:"+orgSlug+":"+userId] = dbPath
t.mu.Unlock()
return dbPath, nil
}
// --- Lookup ---
// GetOrgDBPath returns the database path for an existing org.
func (t *OrgDB) GetOrgDBPath(orgSlug string) (string, bool) {
t.mu.RLock()
if p, ok := t.dbs["org:"+orgSlug]; ok {
t.mu.RUnlock()
return p, true
}
t.mu.RUnlock()
dbPath := t.OrgDBPath(orgSlug)
if _, err := os.Stat(dbPath); err == nil {
t.mu.Lock()
t.dbs["org:"+orgSlug] = dbPath
t.mu.Unlock()
return dbPath, true
}
return "", false
}
// GetUserDBPath returns the database path for an existing user.
func (t *OrgDB) GetUserDBPath(orgSlug, userId string) (string, bool) {
key := "user:" + orgSlug + ":" + userId
t.mu.RLock()
if p, ok := t.dbs[key]; ok {
t.mu.RUnlock()
return p, true
}
t.mu.RUnlock()
dbPath := t.UserDBPath(orgSlug, userId)
if _, err := os.Stat(dbPath); err == nil {
t.mu.Lock()
t.dbs[key] = dbPath
t.mu.Unlock()
return dbPath, true
}
return "", false
}
// --- Lifecycle ---
// DeleteUser removes a user's database directory.
func (t *OrgDB) DeleteUser(orgSlug, userId string) error {
t.mu.Lock()
delete(t.dbs, "user:"+orgSlug+":"+userId)
t.mu.Unlock()
return os.RemoveAll(t.UserDir(orgSlug, userId))
}
// DeleteOrg removes an org's entire directory (including all user databases).
func (t *OrgDB) DeleteOrg(orgSlug string) error {
t.mu.Lock()
// Remove all cached paths for this org
for k := range t.dbs {
if len(k) > 4 && k[:4] == "org:" && k[4:] == orgSlug {
delete(t.dbs, k)
}
if len(k) > 5+len(orgSlug) && k[:5+len(orgSlug)] == "user:"+orgSlug+":" {
delete(t.dbs, k)
}
}
t.mu.Unlock()
return os.RemoveAll(t.OrgDir(orgSlug))
}
// ListOrgs returns all provisioned org slugs.
func (t *OrgDB) ListOrgs() ([]string, error) {
entries, err := os.ReadDir(t.OrgsDir())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var slugs []string
for _, e := range entries {
if e.IsDir() {
if _, err := os.Stat(t.OrgDBPath(e.Name())); err == nil {
slugs = append(slugs, e.Name())
}
}
}
return slugs, nil
}
// ListUsers returns all provisioned user IDs within an org.
func (t *OrgDB) ListUsers(orgSlug string) ([]string, error) {
usersDir := filepath.Join(t.OrgDir(orgSlug), usersDirName)
entries, err := os.ReadDir(usersDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var ids []string
for _, e := range entries {
if e.IsDir() {
if _, err := os.Stat(t.UserDBPath(orgSlug, e.Name())); err == nil {
ids = append(ids, e.Name())
}
}
}
return ids, nil
}