mirror of
https://github.com/hanzoai/orm.git
synced 2026-10-30 15:01:22 +00:00
Tenancy is a column. Every table carries org_id first, leading its key and every index, and the org a statement acts for comes from the context through the resolver the store opens with. The same code runs on SQLite and PostgreSQL; the backend is configuration. The builder refuses a statement whose context names no org before any SQL exists, and constrains every table a statement names: WHERE for the table read, ON for each join, the same for every subquery. An insert takes its org from the context and refuses a row naming another; an update cannot move a row between orgs; an upsert's conflict target leads with org_id. Conditions are a closed set and identifiers are validated, so there is no raw SQL to reach past the constraint with. On PostgreSQL the migrator forces row-level security under a tenant policy (org_id = app.org) and a platform policy, and proves the table on every open. Each statement runs in a transaction that sets its role and app.org, both transaction-local; the login is NOINHERIT and holds nothing itself. Open refuses a superuser, BYPASSRLS, an inheriting login, or roles other than the ones Provision makes. Platform(ctx, why) is the one cross-org entry: audited, its own role, refused from any context that names an org. Tx is SERIALIZABLE on PostgreSQL and reruns on conflict. ReadOnly routes a read to a replica. One suite runs every behaviour on SQLite and on a real PostgreSQL, and holds row-level security alone with raw SQL on the store's own pool. Co-authored-by: Hanzo Dev <[email protected]>
102 lines
2.7 KiB
Go
102 lines
2.7 KiB
Go
package tenant
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// maxIdent is PostgreSQL's identifier length. A longer name is truncated by
|
|
// the server with only a notice, so two names that agree for 63 bytes would
|
|
// silently be one — refused here instead, on both backends.
|
|
const maxIdent = 63
|
|
|
|
// ident validates one identifier: lowercase letters, digits and underscores,
|
|
// not starting with a digit. That is the whole grammar a name has here, which
|
|
// is what lets a name be quoted rather than escaped.
|
|
func ident(s string) error {
|
|
if s == "" {
|
|
return fmt.Errorf("empty identifier")
|
|
}
|
|
if len(s) > maxIdent {
|
|
return fmt.Errorf("identifier %.20q… is longer than %d bytes", s, maxIdent)
|
|
}
|
|
for i, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r == '_':
|
|
case r >= '0' && r <= '9' && i > 0:
|
|
default:
|
|
return fmt.Errorf("identifier %q: only lowercase letters, digits and _", s)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ref validates a column reference: name, or qualifier.name.
|
|
func ref(s string) error {
|
|
q, n, ok := strings.Cut(s, ".")
|
|
if !ok {
|
|
return ident(s)
|
|
}
|
|
if err := ident(q); err != nil {
|
|
return err
|
|
}
|
|
return ident(n)
|
|
}
|
|
|
|
// quote renders a validated identifier.
|
|
func (st *store) quote(name string) string { return st.dialect.Quote(name) }
|
|
|
|
// col renders a validated column reference.
|
|
func (st *store) col(s string) string {
|
|
if q, n, ok := strings.Cut(s, "."); ok {
|
|
return st.quote(q) + "." + st.quote(n)
|
|
}
|
|
return st.quote(s)
|
|
}
|
|
|
|
// table renders a table's name, qualified by the schema on PostgreSQL.
|
|
func (st *store) table(name string) string {
|
|
if st.pg {
|
|
return st.quote(st.schema) + "." + st.quote(name)
|
|
}
|
|
return st.quote(name)
|
|
}
|
|
|
|
// source is a table a statement reads: its name and the name the statement
|
|
// calls it by.
|
|
type source struct {
|
|
table string
|
|
alias string // == table when none was given
|
|
}
|
|
|
|
// parseSource reads "table", "table alias" or "table AS alias".
|
|
func (st *store) parseSource(s string) (source, error) {
|
|
f := strings.Fields(s)
|
|
var src source
|
|
switch {
|
|
case len(f) == 1:
|
|
src = source{f[0], f[0]}
|
|
case len(f) == 2:
|
|
src = source{f[0], f[1]}
|
|
case len(f) == 3 && strings.EqualFold(f[1], "as"):
|
|
src = source{f[0], f[2]}
|
|
default:
|
|
return source{}, fmt.Errorf("tenant: table %q: want \"table\", \"table alias\" or \"table AS alias\"", s)
|
|
}
|
|
if err := ident(src.table); err != nil {
|
|
return source{}, fmt.Errorf("tenant: table: %w", err)
|
|
}
|
|
if err := ident(src.alias); err != nil {
|
|
return source{}, fmt.Errorf("tenant: alias: %w", err)
|
|
}
|
|
if st.tables[src.table] == nil {
|
|
return source{}, fmt.Errorf("tenant: table %s is not one of this store's tables", src.table)
|
|
}
|
|
return src, nil
|
|
}
|
|
|
|
// from renders a source for FROM or JOIN.
|
|
func (st *store) from(src source) string {
|
|
return st.table(src.table) + " AS " + st.quote(src.alias)
|
|
}
|