tenancy

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 8, 2026 License: AGPL-3.0 Imports: 5 Imported by: 0

Documentation

Overview

Package tenancy carries one dimension: whose data a row is.

A component that stores consumer data stores it for somebody. In a single-tenant application that somebody is the application itself and the dimension is invisible; in a multi-tenant one it is an account, an organization, or a workspace, and every read that omits it is a cross-tenant read. Scope is that dimension, and it is one type rather than a field each component names for itself.

Why it is not a string

Because the mistake this exists to prevent is a filter somebody forgot.

// Before: nothing in the signature says this answers a global question.
EndpointsForEvent(ctx, q, eventType)

// After: there is no call that omits the scope.
EndpointsForEvent(ctx, q, scope, eventType)

An owner identifier typed as string is indistinguishable from every other string a component takes, so the compiler cannot tell "who owns this" from "what happened" and the two can be passed in the wrong order. Worse, the absence of an owner is expressible: the empty string reads as "no filter" in a query that concatenates it, which is a read that returns every tenant's rows.

A Scope cannot be built from nothing. The zero value names nobody, Validate rejects it, and Value refuses to bind it — so a query that lost its scope fails instead of quietly widening. Saying "no owner" is possible, but it has to be said: that is Global.

Global

dispatcher.Dispatch(ctx, q, &webhooks.Delivery{
	Scope:     tenancy.Global(),
	EventType: "system.reindexed",
	Payload:   body,
})

Global is the scope of data that belongs to no tenant — an application whose events are global, a fleet-wide sweep, a platform-level record. It is a scope like any other and matches only itself: rows in it are not visible to a tenant scope, and a tenant's rows are not visible to it.

It is stored as the empty owner identifier, and a single-tenant application that passes Global everywhere behaves exactly as it did before the dimension existed: every row it writes carries the empty scope, every read filters on it, and the filter never excludes anything. What delivers that is the store binding Global on every write, not the column tolerating a write that omitted it — which is why a scope column has no default. See "What a component owes".

Of is the other constructor, and it deliberately does not accept an empty identifier: Of takes an ID the caller holds, and an empty one is a bug rather than a request for the global scope. Say Global when you mean global.

What a component owes

Three things, and the third is the one that gets skipped:

  • Scope in the column. A TEXT column that is NOT NULL and has no DEFAULT, beside the row's own identity — not encoded into another column's value. A composite key like "<accountID>:<eventType>" scopes by construction, which is why it is tempting, and it cannot be indexed, filtered, or enumerated as the two facts it is. The missing default is the same rule as Value's: the empty string is Global rather than "no scope given", so a column that fills it in for a write which named none is a write that acquired a scope by forgetting to have one.

  • Scope in the query. Every predicate that reads or writes consumer data carries it, and the store binds Scope itself rather than a string derived from it, so an unset scope is a driver error rather than a wider result set.

  • No read path that omits it. Not "a scoped variant exists" — the unscoped variant must not be reachable, because the one caller who reaches for it is the one who has not thought about tenancy. A component's own machinery is the exception, and it is a narrow one: a delivery worker draining a queue across every tenant is not a consumer read, it is the component servicing itself, and those methods say so in their documentation.

What is deliberately not here

Resolving a scope from a request, mapping one to an authorization decision, and tenancy hierarchies are all absent. A Scope says whose row it is; it does not say who is asking, or whether they may. Authorization is authorization's job, and this type is not a capability — holding one is not permission to read what it names.

Hierarchy is absent because depth is an application's decision: a two-level user-inside-account model cannot express one level or three. Scope is one opaque identifier for the same reason audit.Entry.Scope and dataprivacy.Subject.Scope are, and an application that needs a path can put one in the identifier.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNoScope indicates a scope that names nobody — the zero Scope, reaching a
	// query or a validation. It wraps errors.ErrEmptyInputParameter, so a caller
	// may check either.
	//
	// It is not the error for "this tenant has no rows", which is an empty result,
	// nor for "this tenant may not read them", which is
	// errors.ErrPermissionDenied. It means the call never said whose data it
	// wanted.
	ErrNoScope = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "no tenancy scope provided")

	// ErrUnscannableScope indicates a scope column that held something a scope
	// cannot be read from — a NULL, or a type no driver should produce for TEXT.
	ErrUnscannableScope = platformerrors.New("cannot scan tenancy scope")
)

Functions

This section is empty.

Types

type Scope

type Scope struct {
	// contains filtered or unexported fields
}

Scope names whose data a row is: an account, an organization, a workspace, or nobody.

It is a struct with unexported fields rather than a named string so that the zero value names nobody and cannot be mistaken for a scope that does. Two scopes are equal when they name the same owner, so Scope is usable as a map key and comparable with ==.

Build one with Of or Global. See the package documentation for why the empty identifier is not a third constructor.

func FromOwner

func FromOwner(ownerID string) Scope

FromOwner reconstructs the scope stored as ownerID, mapping the empty identifier back to Global.

It is the inverse of Owner, for a Store implementation whose backing is not a SQL driver — Scan covers the ones that are. Unlike Of it accepts the empty identifier, because here it arrived from a column that was written rather than from a caller who may have lost it.

func Global

func Global() Scope

Global returns the scope of data belonging to no tenant.

It matches only itself. A global row is not visible to a tenant scope and a tenant's rows are not visible here, so an application whose events are global passes Global everywhere and gets the behavior it had before the dimension existed — because Global's stored identifier is the empty string, which is what a scope column defaults to.

func Of

func Of(ownerID string) Scope

Of returns the scope owned by ownerID.

An empty ownerID yields the zero Scope, which no query accepts. That is deliberate: Of takes an identifier the caller is holding, and an empty one means the caller's own lookup came back empty — a bug that should surface as ErrNoScope rather than as a silent read of the global scope. Call Global when the absence of an owner is the intent.

func (Scope) IsGlobal

func (s Scope) IsGlobal() bool

IsGlobal reports whether this is the scope belonging to no tenant. The zero Scope is not global — it is undecided.

func (Scope) MarshalJSON

func (s Scope) MarshalJSON() ([]byte, error)

MarshalJSON renders the scope as its owner identifier, so a scope on the wire reads the same as the scope columns and the audit log's: "" for Global, the owner's ID otherwise.

An unset scope is null rather than "", because "" is Global's spelling and conflating the two is what makes a client that omitted the field look like one that asked for the global scope.

func (Scope) Owner

func (s Scope) Owner() string

Owner returns the identifier this scope names, which is what goes in a scope column: the owner's ID, or the empty string for Global.

The zero Scope also returns the empty string, so Owner alone cannot tell Global from an unset scope. Validate first, or bind the Scope itself and let Value refuse.

func (*Scope) Scan

func (s *Scope) Scan(src any) error

Scan reads a scope from a scope column, implementing sql.Scanner. The empty identifier is Global, since that is how Global is stored.

A NULL is refused rather than read as Global: the column this package documents is NOT NULL, so a NULL means the schema is not the one the queries were written against, and guessing which scope it meant is how one tenant's rows become another's.

func (Scope) String

func (s Scope) String() string

String renders the scope for a log field or a span attribute.

It is prose, not a column value — the global scope's identifier is the empty string, and "<global>" is only how it reads. Owner and Value are what a query binds. The angle brackets are there so that an owner whose ID happens to be "global" is still distinguishable in a log.

func (*Scope) UnmarshalJSON

func (s *Scope) UnmarshalJSON(data []byte) error

UnmarshalJSON reads the rendering MarshalJSON produces: null (or an absent field) is the unset scope, "" is Global, and anything else is that owner.

The empty string is accepted here where Of refuses it, and the asymmetry is deliberate. Of takes an identifier the caller looked up, so an empty one is a failed lookup; a JSON document instead distinguishes the field being absent from its being present and empty, and the second of those is a client naming the global scope in the only spelling it has.

func (Scope) Validate

func (s Scope) Validate() error

Validate reports whether the scope names anything, returning ErrNoScope when it does not.

It is what a component's entry points call, so that "the caller forgot the scope" is one error with one message wherever it is caught.

func (Scope) Value

func (s Scope) Value() (driver.Value, error)

Value renders the scope as a bound query parameter, implementing driver.Valuer.

An unset scope is an error rather than an empty string. That is the whole reason a store binds the Scope rather than a string it derived: a predicate that lost its scope fails at the driver instead of reading the global scope's rows, and no store implementation has to remember to check.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL