Help us shape the future of Go! Take the 2026 Go Developer Survey

tenancy

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Oct 23, 2026 License: MIT Imports: 17 Imported by: 0

README

go-tenancy

tests Go Reference

Multi-tenant SaaS infrastructure for Go: tenants with slugs and custom domains, memberships with roles and a concurrency-safe last-owner guard, single-use invitations whose tokens are stored only as hashes, HTTP middleware that resolves the tenant of each request, and PostgreSQL row-level security that isolates tenants inside shared tables. Integrity lives in the database, both stores pass the same conformance suite, and the root package imports no database driver.

Install

go get github.com/byteminus/go-tenancy

Requires Go 1.26 or later. The PostgreSQL store and the row-level security helpers are tested against PostgreSQL 16, 17 and 18.

Quick start

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"

	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/byteminus/go-tenancy"
	"github.com/byteminus/go-tenancy/httptenant"
	"github.com/byteminus/go-tenancy/pgxstore"
)

func main() {
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()

	store, err := pgxstore.New(pool)
	if err != nil {
		log.Fatal(err)
	}
	if err := store.CreateSchema(ctx); err != nil {
		log.Fatal(err)
	}
	svc, err := tenancy.New(store, tenancy.Options{ReservedSlugs: []string{"www", "api"}})
	if err != nil {
		log.Fatal(err)
	}

	acme, err := svc.Create(ctx, tenancy.CreateInput{Slug: "acme", Name: "Acme Inc.", Owner: "user-alice"})
	if err != nil {
		log.Fatal(err)
	}
	_, token, err := svc.Invite(ctx, acme.ID, tenancy.InviteInput{
		Email:     "bob@example.com",
		Role:      tenancy.RoleAdmin,
		InvitedBy: tenancy.Ref{Type: "user", ID: "user-alice"},
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("send this link to Bob: https://acme.example.com/join?token=" + token)

	mw, err := httptenant.New(httptenant.Config{
		Loader: svc,
		Resolvers: []httptenant.Resolver{
			httptenant.Subdomain{BaseDomain: "example.com", Ignore: []string{"www"}},
			httptenant.CustomDomain{Exclude: []string{"example.com"}},
		},
	})
	if err != nil {
		log.Fatal(err)
	}
	app := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// The middleware answers 404 itself when there is no tenant.
		t, _ := tenancy.TenantFrom(r.Context())
		fmt.Fprintf(w, "Welcome to %s\n", t.Name)
	})
	log.Fatal(http.ListenAndServe(":8080", mw.Handler(app)))
}

Concepts

Tenant. A customer account: a UUID v7 ID, a unique Slug (1-63 characters of a-z, 0-9 and -, so it is always a valid subdomain), a Name, a Status (active or suspended), unique custom Domains (lowercase ASCII host names; internationalized names in punycode) and a Metadata JSON object of at most 64 KiB (MaxMetadataSize). Slugs and domains are trimmed and lowercased before validation, and only ASCII is accepted: non-ASCII input is rejected rather than folded, so a Unicode look-alike can never normalize onto another tenant's slug or domain. Options.ReservedSlugs blocks slugs such as www.

Membership. Links a user ID (your user system's string ID) to a tenant with a Role: owner, admin, member or any custom role matching [a-z][a-z0-9_:.-]{0,63}. Each user has at most one membership per tenant. Only owner is special: removing or demoting the last owner of a tenant returns ErrLastOwner, also under concurrent requests and inside caller transactions at any isolation level. Create can make the first owner in the same atomic write. Members and UserMemberships are keyset-paged.

Invitation. Invite returns the invitation and a 256-bit token from crypto/rand, once; the store keeps only its SHA-256 hash, and the hash a store returns is compared with the presented token's in constant time. InvitedBy is a Ref (Ref{Type: "user", ID: "42"}), so invitations sent by API clients or jobs are recorded as such. AcceptInvitation(token, userID) checks that the invitation is pending, creates the membership with the invited role and marks the invitation accepted, all in one atomic operation; concurrent acceptances succeed at most once. Invitations expire (Options.InvitationTTL, default seven days, or InviteInput.TTL), can be revoked, and re-inviting an email replaces its pending invitation, so only the newest token works. InvitationByToken and Invitation.StatusAt let a join page show what the invitation is for; PendingInvitations is keyset-paged. The package does not know users' email addresses and does not check that the accepting user owns the invited address. If that matters, compare InvitationByToken(token).Email with the user's verified address before AcceptInvitation; a token always belongs to the same invitation and address, so the check cannot go stale.

Request tenant. WithTenant and TenantFrom carry the tenant in a context.Context; TenantIDFrom returns its ID as a string. The httptenant middleware tries its resolvers in order (Subdomain, CustomDomain, Header, PathPrefix, PathValue, or your own Resolver), loads the tenant through a TTL cache, answers 404 for unknown tenants and 403 for suspended ones (both handlers configurable) and stores the tenant in the context. The first resolver that matches decides: an unknown host never falls through to another resolver or a default tenant. Host parsing strips ports, case and a trailing dot, rejects IPv4 and IPv6 literals, non-ASCII and malformed hosts, and reads X-Forwarded-Host only when TrustForwardedHost is set, and then only a single unambiguous value. For an absolute-form request (GET http://acme.example.com/ HTTP/1.1) net/http takes the host from the request target, not the Host header (RFC 9112); a proxy that routes by the Host header must rewrite such request lines to origin form or set X-Forwarded-Host. Only tenants that exist are cached (there is no negative cache), so unknown hosts can neither poison nor flush the cache. Forget drops a tenant's entries and discards loads in flight; when a domain moves to another tenant, forget the tenant that lost it. The middleware decides which tenant a request is for, not whether the caller may act in it: check membership in your handlers.

Row-level security. rls.Policy{Table: "invoices"}.EnableSQL() returns DDL that enables and forces row-level security on your table and creates a policy limiting every command to rows whose tenant_id equals the transaction's app.tenant_id setting (column name and type configurable). rls.InTenantTx(ctx, pool, tenantID, fn) runs fn in a transaction after set_config('app.tenant_id', $1, true), so the setting is parameterized and never outlives the transaction: a pooled connection never carries one tenant into the next caller's work. With no tenant set, every row is denied, for reads and writes. Superusers and BYPASSRLS roles always ignore policies, and a table's owner ignores them unless the table has FORCE ROW LEVEL SECURITY (which EnableSQL sets). Connect the application as an ordinary role that was only granted privileges on the tables. Views run with their owner's rights, so a view over a protected table created by the migration role shows every tenant's rows: create it WITH (security_invoker = true) (PostgreSQL 15 or later). SECURITY DEFINER functions likewise run as their owner. InTenantTx also clears any session-level SET app.tenant_id made inside fn before committing. Isolation holds for SQL the application controls: any role can change the setting, so an SQL injection can switch tenants.

PostgreSQL

pgxstore.New(db) accepts a *pgxpool.Pool, *pgx.Conn or pgx.Tx. The four tables are named <prefix>tenants, <prefix>domains, <prefix>memberships and <prefix>invitations; WithTablePrefix changes the default prefix tenancy_ and may be schema-qualified ("app.tenancy_"). store.Schema() returns idempotent DDL for migration tools and store.CreateSchema(ctx) runs it. With the default prefix:

CREATE TABLE IF NOT EXISTS "tenancy_tenants" (
    id         uuid        NOT NULL,
    slug       text        NOT NULL,
    name       text        NOT NULL,
    status     text        NOT NULL,
    metadata   jsonb       NOT NULL DEFAULT '{}',
    created_at timestamptz NOT NULL,
    updated_at timestamptz NOT NULL,
    CONSTRAINT "tenancy_tenants_pkey" PRIMARY KEY (id),
    CONSTRAINT "tenancy_tenants_slug_key" UNIQUE (slug),
    CONSTRAINT "tenancy_tenants_slug_check" CHECK (slug ~ '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$'),
    CONSTRAINT "tenancy_tenants_name_check" CHECK (char_length(name) BETWEEN 1 AND 200),
    CONSTRAINT "tenancy_tenants_status_check" CHECK (status IN ('active', 'suspended')),
    CONSTRAINT "tenancy_tenants_metadata_check" CHECK (jsonb_typeof(metadata) = 'object'),
    CONSTRAINT "tenancy_tenants_updated_at_check" CHECK (updated_at >= created_at)
);

CREATE TABLE IF NOT EXISTS "tenancy_domains" (
    domain     text COLLATE "C" NOT NULL,
    tenant_id  uuid        NOT NULL,
    created_at timestamptz NOT NULL,
    CONSTRAINT "tenancy_domains_pkey" PRIMARY KEY (domain),
    CONSTRAINT "tenancy_domains_tenant_id_fkey" FOREIGN KEY (tenant_id)
        REFERENCES "tenancy_tenants" (id) ON DELETE CASCADE,
    CONSTRAINT "tenancy_domains_domain_check" CHECK (domain ~ '^[a-z0-9.-]{3,253}$')
);

CREATE INDEX IF NOT EXISTS "tenancy_domains_tenant_id_idx" ON "tenancy_domains" (tenant_id);

CREATE TABLE IF NOT EXISTS "tenancy_memberships" (
    tenant_id  uuid             NOT NULL,
    user_id    text COLLATE "C" NOT NULL,
    role       text             NOT NULL,
    created_at timestamptz      NOT NULL,
    updated_at timestamptz      NOT NULL,
    CONSTRAINT "tenancy_memberships_pkey" PRIMARY KEY (tenant_id, user_id),
    CONSTRAINT "tenancy_memberships_tenant_id_fkey" FOREIGN KEY (tenant_id)
        REFERENCES "tenancy_tenants" (id) ON DELETE CASCADE,
    CONSTRAINT "tenancy_memberships_user_id_check" CHECK (octet_length(user_id) BETWEEN 1 AND 255),
    CONSTRAINT "tenancy_memberships_role_check" CHECK (role ~ '^[a-z][a-z0-9_:.-]{0,63}$'),
    CONSTRAINT "tenancy_memberships_updated_at_check" CHECK (updated_at >= created_at)
);

-- Serves UserMemberships, which pages a user's memberships by tenant ID.
CREATE INDEX IF NOT EXISTS "tenancy_memberships_user_id_tenant_id_idx" ON "tenancy_memberships" (user_id, tenant_id);

CREATE TABLE IF NOT EXISTS "tenancy_invitations" (
    id          uuid             NOT NULL,
    tenant_id   uuid             NOT NULL,
    email       text             NOT NULL,
    role        text             NOT NULL,
    token_hash  bytea            NOT NULL,
    invited_by_type text COLLATE "C" NOT NULL DEFAULT '',
    invited_by_id   text COLLATE "C" NOT NULL DEFAULT '',
    created_at  timestamptz      NOT NULL,
    expires_at  timestamptz      NOT NULL,
    accepted_at timestamptz,
    accepted_by text COLLATE "C",
    revoked_at  timestamptz,
    CONSTRAINT "tenancy_invitations_pkey" PRIMARY KEY (id),
    CONSTRAINT "tenancy_invitations_tenant_id_fkey" FOREIGN KEY (tenant_id)
        REFERENCES "tenancy_tenants" (id) ON DELETE CASCADE,
    CONSTRAINT "tenancy_invitations_token_hash_key" UNIQUE (token_hash),
    CONSTRAINT "tenancy_invitations_email_check" CHECK (position('@' IN email) > 1),
    CONSTRAINT "tenancy_invitations_role_check" CHECK (role ~ '^[a-z][a-z0-9_:.-]{0,63}$'),
    CONSTRAINT "tenancy_invitations_token_hash_check" CHECK (octet_length(token_hash) = 32),
    CONSTRAINT "tenancy_invitations_expires_at_check" CHECK (expires_at > created_at),
    CONSTRAINT "tenancy_invitations_accepted_check" CHECK ((accepted_at IS NULL) = (accepted_by IS NULL)),
    CONSTRAINT "tenancy_invitations_state_check" CHECK (accepted_at IS NULL OR revoked_at IS NULL),
    CONSTRAINT "tenancy_invitations_invited_by_check" CHECK ((invited_by_type = '') = (invited_by_id = ''))
);

-- At most one pending invitation per tenant and email; PutInvitation
-- replaces it through ON CONFLICT on this index.
CREATE UNIQUE INDEX IF NOT EXISTS "tenancy_invitations_pending_key"
    ON "tenancy_invitations" (tenant_id, email)
    WHERE accepted_at IS NULL AND revoked_at IS NULL;

-- Serves PendingInvitations, which pages a tenant's pending invitations by
-- ID.
CREATE INDEX IF NOT EXISTS "tenancy_invitations_pending_id_idx"
    ON "tenancy_invitations" (tenant_id, id)
    WHERE accepted_at IS NULL AND revoked_at IS NULL;

The last-owner guard locks the tenant row (SELECT ... FOR NO KEY UPDATE) and then the affected owner memberships (FOR UPDATE), invitation acceptance locks the invitation row, and re-invitation is a single upsert on the partial unique index, so these rules hold across processes. In a caller's REPEATABLE READ or SERIALIZABLE transaction a conflicting guard fails with a serialization error (SQLSTATE 40001) instead of breaking the rule; retry the transaction.

Joining transactions with WithConn

To make the store's writes part of a transaction your application already carries in its context, resolve the connection per call. Returning nil falls back to the pool:

type txKey struct{}

func newStore(pool *pgxpool.Pool) (*pgxstore.Store, error) {
	return pgxstore.New(pool, pgxstore.WithConn(func(ctx context.Context) pgxstore.Querier {
		if tx, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
			return tx
		}
		return nil
	}))
}

Operations that need their own transaction (creating a tenant with domains, the last-owner guard, accepting an invitation) open a savepoint inside yours.

Row-level security
// Run the EnableSQL DDL in a migration; DisableSQL is the Down step.
func invoicePolicy() (up, down string, err error) {
	p := rls.Policy{Table: "app.invoices", Column: "account_id"}
	if up, err = p.EnableSQL(); err != nil {
		return "", "", err
	}
	down, err = p.DisableSQL()
	return up, down, err
}

func invoiceCount(ctx context.Context, pool *pgxpool.Pool, tenantID uuid.UUID) (int, error) {
	var n int
	err := rls.InTenantTx(ctx, pool, tenantID, func(ctx context.Context, tx pgx.Tx) error {
		// No WHERE clause: the policy limits the query to this tenant.
		return tx.QueryRow(ctx, `SELECT count(*) FROM app.invoices`).Scan(&n)
	})
	return n, err
}

API overview

Package Contents
tenancy New; Service (Create, Get, BySlug, ByDomain, Update, Suspend, Activate, List, AddDomain, RemoveDomain, AddMember, Member, Members, UserMemberships, SetRole, RemoveMember, Invite, InvitationByToken, Invitation, AcceptInvitation, RevokeInvitation, PendingInvitations); types Tenant, Membership, Invitation, Ref, Role, Status; Store interface; WithTenant, TenantFrom, TenantIDFrom; NormalizeSlug, NormalizeDomain; sentinel errors and ValidationError
httptenant New(Config), Middleware.Handler, Middleware.Forget; resolvers Subdomain, CustomDomain, Header, PathPrefix, PathValue; Resolver, RequestRewriter, Loader; Host
rls InTenantTx, Policy.EnableSQL, Policy.DisableSQL, Setting
pgxstore New, WithTablePrefix, WithConn, Store.Schema, Store.CreateSchema, Querier
memstore New: in-memory store for tests and prototypes (atomic per call, not transactional)
storetest Run(t, newStore): conformance suite for tenancy.Store implementations

Errors: every error matches one of the family sentinels ErrNotFound (ErrTenantNotFound, ErrDomainNotFound, ErrMemberNotFound, ErrInvitationNotFound), ErrConflict (ErrSlugTaken, ErrDomainTaken, ErrMemberExists, ErrLastOwner, ErrInvitationExpired, ErrInvitationAccepted, ErrInvitationRevoked) or ErrInvalid (*ValidationError and configuration errors).

Using it with the other Byte Minus packages

Every Byte Minus package that needs the current tenant has a TenantFrom hook with the signature of tenancy.TenantIDFrom, so the tenant resolved by the httptenant middleware reaches them without an adapter:

activitylog.Options{
	TenantFrom: tenancy.TenantIDFrom, // same signature, no adapter
	ActorFrom: func(ctx context.Context) (activitylog.Ref, bool) {
		u, ok := myapp.UserFrom(ctx)
		return activitylog.Ref{Type: "user", ID: u.ID}, ok
	},
}

Testing

go test -race ./...
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/tenancy?sslmode=disable go test -race ./...
go test -run='^$' -fuzz='^FuzzHost$' -fuzztime=60s ./httptenant

Without TEST_DATABASE_URL the PostgreSQL tests are skipped (they fail when CI is set). Each test works in its own randomly named schema, so runs can share a database. The row-level security tests create temporary roles named tenancy_test_<random> (one owning a table, one only granted privileges on it) and switch to them with SET ROLE, so the database user must be allowed to create roles; the roles and schemas are dropped afterwards.

To check your own store, run the conformance suite:

func TestMyStore(t *testing.T) {
	storetest.Run(t, func(t *testing.T) tenancy.Store { return newMyStore(t) })
}

Roadmap

  • Schema-per-tenant isolation (a schema per tenant with search_path switching) as an alternative to row-level security.
  • Deleting tenants, with a configurable grace period.
  • Internationalized domain names given in Unicode (converted to punycode).
  • Custom domain verification (DNS TXT challenge) before a domain resolves.
  • Request coalescing for concurrent cache misses in httptenant.

Stability

This package follows semantic versioning. Before v1.0.0, a minor release may contain breaking changes; each one is listed in CHANGELOG.md with upgrade steps, including SQL for schema changes. From v1.0.0, the exported API and the PostgreSQL schema change only in backwards-compatible ways until the next major version.

License

MIT; see LICENSE.

Documentation

Overview

Package tenancy is the multi-tenancy core of a SaaS application: tenants, custom domains, memberships with roles, invitations, and the tenant of the current request.

Tenants

A Tenant has a UUID v7 ID, a unique lowercase slug that is a valid DNS label (so it can serve as a subdomain), a display name, a status (active or suspended), any number of unique custom domains and a JSON metadata object. New returns a Service; Service.Create, Get, BySlug, ByDomain, Update, Suspend, Activate, List (keyset-paged), AddDomain and RemoveDomain manage tenants. Slugs and domains are ASCII only: NormalizeSlug and NormalizeDomain reject non-ASCII input instead of folding it.

Memberships

A Membership gives a user, identified by the application's own user ID string, one role in a tenant: RoleOwner, RoleAdmin, RoleMember or any custom role. Every tenant that has an owner keeps at least one: removing or demoting the last owner returns ErrLastOwner, and stores guarantee this under concurrent requests. Service.Members and Service.UserMemberships return keyset-paged results.

Invitations

Service.Invite creates an invitation for an email address and returns a 256-bit random token exactly once; only its SHA-256 hash is stored. Accepting it is single-use, even under concurrency, and creates the membership atomically. Invitations expire, can be revoked, and re-inviting an address replaces its pending invitation. InvitedBy is a Ref, so the sender can be a user, an API client, a job or the system.

Request tenant

WithTenant and TenantFrom carry the current tenant in a context.Context. TenantIDFrom returns just its ID as a string, the shape the TenantFrom hook of every other Byte Minus package accepts:

activitylog.Options{TenantFrom: tenancy.TenantIDFrom}

The httptenant subpackage resolves the tenant from HTTP requests (subdomain, custom domain, header or path) and the rls subpackage enforces tenant isolation in PostgreSQL with row-level security.

Storage

The Service persists through a Store: memstore for tests and prototypes, pgxstore for PostgreSQL. The storetest package is the conformance suite both pass.

Errors

Errors are sentinel values such as ErrTenantNotFound or ErrSlugTaken, matched with errors.Is. Each wraps one of the family sentinels ErrNotFound, ErrInvalid or ErrConflict, so an application can map them to HTTP statuses without knowing every specific error. Invalid input yields a *ValidationError, which matches ErrInvalid and names the offending field.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log"

	"github.com/byteminus/go-tenancy"
	"github.com/byteminus/go-tenancy/memstore"
)

func main() {
	ctx := context.Background()
	svc, err := tenancy.New(memstore.New(), tenancy.Options{ReservedSlugs: []string{"www", "api"}})
	if err != nil {
		log.Fatal(err)
	}

	acme, err := svc.Create(ctx, tenancy.CreateInput{
		Slug:    "Acme",
		Name:    "Acme Inc.",
		Domains: []string{"app.acme.test"},
		Owner:   "user-alice",
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(acme.Slug, acme.Status, acme.Domains)

	// The token goes to the invitee, typically inside a link; only its
	// hash is stored.
	_, token, err := svc.Invite(ctx, acme.ID, tenancy.InviteInput{Email: "Bob@Example.com", Role: tenancy.RoleAdmin, InvitedBy: tenancy.Ref{Type: "user", ID: "user-alice"}})
	if err != nil {
		log.Fatal(err)
	}
	m, err := svc.AcceptInvitation(ctx, token, "user-bob")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(m.UserID, m.Role)

	_, err = svc.AcceptInvitation(ctx, token, "user-mallory")
	fmt.Println(errors.Is(err, tenancy.ErrInvitationAccepted))

	found, err := svc.ByDomain(ctx, "APP.acme.test")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(found.Name)
}
Output:
acme active [app.acme.test]
user-bob admin
true
Acme Inc.

Index

Examples

Constants

View Source
const (
	// DefaultInvitationTTL is how long invitations stay valid when neither
	// Options.InvitationTTL nor InviteInput.TTL is set.
	DefaultInvitationTTL = 7 * 24 * time.Hour
	// DefaultPageSize is the page size used when a list call passes a zero
	// limit.
	DefaultPageSize = 50
	// MaxPageSize caps the page size of list calls.
	MaxPageSize = 500

	// MinInvitationTTL is the shortest invitation lifetime accepted.
	MinInvitationTTL = time.Second
)
View Source
const MaxMetadataSize = 64 << 10

MaxMetadataSize is the largest Metadata object, in bytes of input JSON, that the Service accepts. Metadata is copied into every cached tenant and every request context, so it is meant for small settings, not documents.

Variables

View Source
var (
	// ErrNotFound is matched by every error reporting that a record does
	// not exist.
	ErrNotFound = errors.New("tenancy: not found")
	// ErrInvalid is matched by every *ValidationError and by configuration
	// errors from New.
	ErrInvalid = errors.New("tenancy: invalid input")
	// ErrConflict is matched by every error reporting a uniqueness
	// violation or a state that no longer allows the operation.
	ErrConflict = errors.New("tenancy: conflict")
)

The family sentinels. Every error this package and its stores return for a missing record, invalid input or a conflicting state matches one of them with errors.Is, so an application can map all Byte Minus packages to HTTP statuses the same way.

View Source
var (
	// ErrTenantNotFound is returned when a tenant does not exist. It
	// matches ErrNotFound.
	ErrTenantNotFound = fmt.Errorf("tenancy: tenant not found: %w", ErrNotFound)
	// ErrSlugTaken is returned when another tenant already uses the slug.
	// It matches ErrConflict.
	ErrSlugTaken = fmt.Errorf("tenancy: slug already taken: %w", ErrConflict)
	// ErrDomainTaken is returned when a domain already belongs to a tenant,
	// including the same tenant. It matches ErrConflict.
	ErrDomainTaken = fmt.Errorf("tenancy: domain already taken: %w", ErrConflict)
	// ErrDomainNotFound is returned when removing a domain the tenant does
	// not have. It matches ErrNotFound.
	ErrDomainNotFound = fmt.Errorf("tenancy: domain not found: %w", ErrNotFound)

	// ErrMemberNotFound is returned when the user is not a member of the
	// tenant (or the tenant does not exist). It matches ErrNotFound.
	ErrMemberNotFound = fmt.Errorf("tenancy: member not found: %w", ErrNotFound)
	// ErrMemberExists is returned when the user is already a member. It
	// matches ErrConflict.
	ErrMemberExists = fmt.Errorf("tenancy: member already exists: %w", ErrConflict)
	// ErrLastOwner is returned when removing or demoting the only remaining
	// owner of a tenant. It matches ErrConflict.
	ErrLastOwner = fmt.Errorf("tenancy: cannot remove or demote the last owner: %w", ErrConflict)

	// ErrInvitationNotFound is returned for unknown invitations and for
	// tokens that are malformed or do not match any invitation. It matches
	// ErrNotFound.
	ErrInvitationNotFound = fmt.Errorf("tenancy: invitation not found: %w", ErrNotFound)
	// ErrInvitationExpired is returned when accepting an expired
	// invitation. It matches ErrConflict.
	ErrInvitationExpired = fmt.Errorf("tenancy: invitation expired: %w", ErrConflict)
	// ErrInvitationAccepted is returned when an invitation was already
	// used. It matches ErrConflict.
	ErrInvitationAccepted = fmt.Errorf("tenancy: invitation already accepted: %w", ErrConflict)
	// ErrInvitationRevoked is returned when an invitation was revoked. It
	// matches ErrConflict.
	ErrInvitationRevoked = fmt.Errorf("tenancy: invitation revoked: %w", ErrConflict)
)

Package-specific errors. Each wraps one of the family sentinels. Stores must return these (possibly wrapped further) so that callers can rely on errors.Is regardless of the backing store.

Functions

func NormalizeDomain

func NormalizeDomain(s string) (string, error)

NormalizeDomain trims ASCII white space from s, lowercases it, drops a single trailing dot, and checks that the result is a fully qualified ASCII host name: at least two labels of a-z, 0-9 and '-', each 1 to 63 characters and not starting or ending with '-', a top-level label that is not all digits, and at most 253 characters in total. Internationalized names must be given in punycode ("xn--") form; any non-ASCII byte is rejected rather than folded. IP addresses and ports are rejected. It returns a *ValidationError otherwise.

Example
package main

import (
	"fmt"

	"github.com/byteminus/go-tenancy"
)

func main() {
	d, err := tenancy.NormalizeDomain(" Shop.Example.COM. ")
	fmt.Println(d, err)
	_, err = tenancy.NormalizeDomain("127.0.0.1")
	fmt.Println(err)
}
Output:
shop.example.com <nil>
tenancy: invalid domain: top-level label must not be numeric

func NormalizeSlug

func NormalizeSlug(s string) (string, error)

NormalizeSlug trims ASCII white space from s, lowercases it and checks that the result is a valid slug: 1 to 63 characters of a-z, 0-9 and '-', not starting or ending with '-'. Any non-ASCII byte is rejected rather than folded. It returns a *ValidationError otherwise.

func TenantIDFrom added in v0.2.0

func TenantIDFrom(ctx context.Context) (string, bool)

TenantIDFrom returns the ID of the tenant stored by WithTenant in canonical UUID form. It returns false when ctx carries no tenant or the stored tenant has the nil ID, so a half-built Tenant never reads as the tenant "00000000-0000-0000-0000-000000000000".

Its signature matches the TenantFrom hook of the other Byte Minus packages, which accept it directly:

activitylog.Options{TenantFrom: tenancy.TenantIDFrom}
Example

TenantIDFrom has the signature of the TenantFrom hook in the other Byte Minus packages, so it can be passed to them directly.

package main

import (
	"context"
	"fmt"

	"github.com/google/uuid"

	"github.com/byteminus/go-tenancy"
)

func main() {
	// The shape of a sibling package's Options, such as activitylog's.
	opts := struct {
		TenantFrom func(ctx context.Context) (string, bool)
	}{TenantFrom: tenancy.TenantIDFrom}

	ctx := tenancy.WithTenant(context.Background(), tenancy.Tenant{ID: uuid.MustParse("0192f1c4-7d2a-7c3e-9b1a-1234567890ab")})
	fmt.Println(opts.TenantFrom(ctx))
	fmt.Println(opts.TenantFrom(context.Background()))
}
Output:
0192f1c4-7d2a-7c3e-9b1a-1234567890ab true
 false

func WithTenant

func WithTenant(ctx context.Context, t Tenant) context.Context

WithTenant returns a copy of ctx that carries t. The httptenant middleware calls it for every resolved request; background jobs can call it directly.

Example
package main

import (
	"context"
	"fmt"

	"github.com/byteminus/go-tenancy"
)

func main() {
	ctx := tenancy.WithTenant(context.Background(), tenancy.Tenant{Slug: "acme"})
	if t, ok := tenancy.TenantFrom(ctx); ok {
		fmt.Println(t.Slug)
	}
}
Output:
acme

Types

type CreateInput

type CreateInput struct {
	// Slug is required; it is normalized with NormalizeSlug.
	Slug string
	// Name is required.
	Name string
	// Domains are optional custom domains, normalized with
	// NormalizeDomain.
	Domains []string
	// Metadata is an optional JSON object; it defaults to {}.
	Metadata json.RawMessage
	// Owner, when not empty, is the user ID that becomes the tenant's
	// first owner in the same atomic write.
	Owner string
}

CreateInput describes a new tenant.

type Invitation

type Invitation struct {
	// ID is a UUID v7. Re-inviting an email replaces the pending invitation,
	// which gives it a new ID and token.
	ID uuid.UUID
	// TenantID is the inviting tenant.
	TenantID uuid.UUID
	// Email is lowercase.
	Email string
	// Role is the role the invitee receives on acceptance.
	Role Role
	// TokenHash is the SHA-256 digest of the invitation token.
	TokenHash []byte
	// InvitedBy is the party that sent the invitation: a user, an API
	// client, a job or the system. It is the zero Ref when not recorded.
	InvitedBy Ref
	// CreatedAt is when the invitation was issued, in UTC.
	CreatedAt time.Time
	// ExpiresAt is the first instant at which the invitation can no
	// longer be accepted, in UTC.
	ExpiresAt time.Time
	// AcceptedAt and AcceptedBy are set together when the invitation is
	// accepted. AcceptedBy is the user ID of the membership the
	// acceptance created, so it is always a user account, not a Ref.
	AcceptedAt *time.Time
	AcceptedBy string
	// RevokedAt is set when the invitation is revoked.
	RevokedAt *time.Time
}

Invitation offers a role in a tenant to an email address. The secret token is returned once by Service.Invite; only its SHA-256 hash is stored.

func (Invitation) StatusAt

func (i Invitation) StatusAt(now time.Time) InvitationStatus

StatusAt reports the state of the invitation at the given time.

type InvitationListOptions added in v0.2.0

type InvitationListOptions struct {
	// After is the Next value of the previous page; uuid.Nil starts from
	// the beginning.
	After uuid.UUID
	// Limit is the page size: zero means DefaultPageSize, and values above
	// MaxPageSize are capped.
	Limit int
}

InvitationListOptions selects a page of invitations.

type InvitationPage added in v0.2.0

type InvitationPage struct {
	// Invitations holds at most the requested number of invitations.
	Invitations []Invitation
	// Next is the After value for the following page, or uuid.Nil when
	// this is the last page.
	Next uuid.UUID
}

InvitationPage is one page of invitations ordered by ID (creation order, since IDs are UUID v7).

type InvitationQuery added in v0.2.0

type InvitationQuery struct {
	// After is the exclusive lower bound on the invitation ID.
	After uuid.UUID
	// Limit is the maximum number of invitations to return; always
	// positive.
	Limit int
}

InvitationQuery selects a page of invitations for Store.PendingInvitations.

type InvitationStatus

type InvitationStatus string

InvitationStatus is the derived state of an invitation at a point in time.

const (
	// InvitationPending invitations can still be accepted.
	InvitationPending InvitationStatus = "pending"
	// InvitationAccepted invitations were used and created a membership.
	InvitationAccepted InvitationStatus = "accepted"
	// InvitationRevoked invitations were withdrawn before use.
	InvitationRevoked InvitationStatus = "revoked"
	// InvitationExpired invitations were neither used nor revoked before
	// their expiry.
	InvitationExpired InvitationStatus = "expired"
)

type InviteInput

type InviteInput struct {
	// Email is required; it is lowercased.
	Email string
	// Role is the role the invitee receives on acceptance.
	Role Role
	// InvitedBy is the optional party sending the invitation, such as
	// Ref{Type: "user", ID: userID}. The zero Ref records nobody.
	InvitedBy Ref
	// TTL overrides Options.InvitationTTL when not zero; it must be at
	// least MinInvitationTTL.
	TTL time.Duration
}

InviteInput describes an invitation.

type ListOptions

type ListOptions struct {
	// Status filters by status when not empty.
	Status Status
	// After is the Next value of the previous page; uuid.Nil starts from
	// the beginning.
	After uuid.UUID
	// Limit is the page size: zero means DefaultPageSize, and values above
	// MaxPageSize are capped.
	Limit int
}

ListOptions selects a page of tenants.

type MemberListOptions

type MemberListOptions struct {
	// After is the Next value of the previous page; empty starts from the
	// beginning.
	After string
	// Limit is the page size: zero means DefaultPageSize, and values above
	// MaxPageSize are capped.
	Limit int
}

MemberListOptions selects a page of memberships.

type MemberPage

type MemberPage struct {
	// Members holds at most the requested number of memberships.
	Members []Membership
	// Next is the After value for the following page, or empty when this
	// is the last page.
	Next string
}

MemberPage is one page of memberships ordered by user ID in byte order.

type MemberQuery

type MemberQuery struct {
	// After is the exclusive lower bound on the user ID, in byte order.
	After string
	// Limit is the maximum number of memberships to return; always
	// positive.
	Limit int
}

MemberQuery selects a page of memberships for Store.ListMembers.

type Membership

type Membership struct {
	// TenantID is the tenant the membership belongs to.
	TenantID uuid.UUID
	// UserID identifies the user in the application's own user system.
	UserID string
	// Role is the user's role in the tenant.
	Role Role
	// CreatedAt and UpdatedAt are UTC timestamps with microsecond precision.
	CreatedAt time.Time
	UpdatedAt time.Time
}

Membership links a user to a tenant with a role. A user has at most one membership per tenant.

type Options

type Options struct {
	// Now returns the current time; it defaults to time.Now. Times are
	// stored in UTC truncated to microseconds, the precision of
	// PostgreSQL timestamps.
	Now func() time.Time
	// InvitationTTL is the default lifetime of invitations; it defaults to
	// DefaultInvitationTTL and must be at least MinInvitationTTL.
	InvitationTTL time.Duration
	// ReservedSlugs are slugs that tenants may not use, such as "www",
	// "api" or "admin" when tenants are served on subdomains. Each must be
	// a valid slug (ASCII only) and is compared after NormalizeSlug, so
	// "WWW" reserves "www".
	ReservedSlugs []string
}

Options configures a Service. The zero value is ready to use.

type Ref added in v0.2.0

type Ref struct {
	// Type names the kind of record, such as "user", "api_client", "job"
	// or "system": 1 to 64 bytes of a-z, 0-9 and '_', starting with a
	// letter.
	Type string `json:"type"`
	// ID identifies the record within its type: 1 to 255 bytes of valid
	// UTF-8 without control characters or surrounding whitespace.
	ID string `json:"id"`
}

Ref identifies a record or party by type and ID, such as Ref{Type: "user", ID: "42"} or Ref{Type: "api_token", ID: "42"}, so parties of different kinds can never be confused. A stored Ref is either zero or has both fields set; the Service rejects a half-empty Ref with a *ValidationError.

func (Ref) IsZero added in v0.2.0

func (r Ref) IsZero() bool

IsZero reports whether both fields are empty.

func (Ref) String added in v0.2.0

func (r Ref) String() string

String returns "type:id" for logs and display. It is not a key: a type or ID containing ':' makes it ambiguous, so compare Refs as structs.

type Role

type Role string

Role is the role a user holds in a tenant. The predefined roles cover the common case; any other value matching the role syntax (lowercase letters, digits and "_:.-", starting with a letter, at most 64 bytes) is accepted as a custom role. Only RoleOwner has meaning to this package: it is the role protected by the last-owner guard.

const (
	// RoleOwner members can do everything, including managing other owners.
	// Every tenant that has an owner keeps at least one.
	RoleOwner Role = "owner"
	// RoleAdmin is a conventional role for members who manage the tenant.
	RoleAdmin Role = "admin"
	// RoleMember is a conventional role for ordinary members.
	RoleMember Role = "member"
)

type Service

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

Service is the entry point for managing tenants, memberships and invitations. It validates and normalizes input and delegates persistence to a Store. A Service is safe for concurrent use when its Store is.

Invalid input yields a *ValidationError (matching ErrInvalid); missing records yield errors matching ErrNotFound and conflicting states errors matching ErrConflict, as documented per method. Store failures are returned as the store reports them. Lookups by an ID that is uuid.Nil find nothing.

func New added in v0.2.0

func New(store Store, opts Options) (*Service, error)

New returns a Service backed by store. It returns an error matching ErrInvalid when store is nil, Options.InvitationTTL is set below MinInvitationTTL, or a reserved slug is malformed.

func (*Service) AcceptInvitation

func (s *Service) AcceptInvitation(ctx context.Context, token, userID string) (Membership, error)

AcceptInvitation makes userID a member of the inviting tenant with the invited role and consumes the invitation, atomically. A token can be used once, even under concurrent calls. It returns a *ValidationError, ErrInvitationNotFound (also for malformed tokens), ErrInvitationAccepted, ErrInvitationRevoked, ErrInvitationExpired or ErrMemberExists, and changes nothing in those cases.

The package does not know users' email addresses and does not check that userID owns the invited address. When that matters, compare the address from InvitationByToken with the user's verified address before calling; the check cannot go stale, because a token always belongs to the same invitation and email (re-inviting issues a new token).

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log"

	"github.com/byteminus/go-tenancy"
	"github.com/byteminus/go-tenancy/memstore"
)

func main() {
	ctx := context.Background()
	svc, err := tenancy.New(memstore.New(), tenancy.Options{})
	if err != nil {
		log.Fatal(err)
	}
	t, err := svc.Create(ctx, tenancy.CreateInput{Slug: "join", Name: "Join", Owner: "alice"})
	if err != nil {
		log.Fatal(err)
	}
	_, token, err := svc.Invite(ctx, t.ID, tenancy.InviteInput{Email: "bob@example.com", Role: tenancy.RoleAdmin})
	if err != nil {
		log.Fatal(err)
	}

	// The join page shows the invitation, and checks the signed-in user's
	// verified address against it when that matters to the application.
	inv, err := svc.InvitationByToken(ctx, token)
	if err != nil {
		log.Fatal(err)
	}
	verifiedEmail := "bob@example.com"
	if inv.Email != verifiedEmail {
		log.Fatal("invitation is for another address")
	}
	m, err := svc.AcceptInvitation(ctx, token, "user-bob")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(m.UserID, m.Role)

	_, err = svc.AcceptInvitation(ctx, token, "user-eve")
	fmt.Println(errors.Is(err, tenancy.ErrInvitationAccepted), errors.Is(err, tenancy.ErrConflict))
}
Output:
user-bob admin
true true

func (*Service) Activate

func (s *Service) Activate(ctx context.Context, id uuid.UUID) (Tenant, error)

Activate marks the tenant active. Activating an active tenant is not an error. It returns ErrTenantNotFound for an unknown tenant.

func (*Service) AddDomain

func (s *Service) AddDomain(ctx context.Context, id uuid.UUID, domain string) (Tenant, error)

AddDomain attaches a custom domain to the tenant. It returns a *ValidationError, ErrTenantNotFound or ErrDomainTaken.

func (*Service) AddMember

func (s *Service) AddMember(ctx context.Context, tenantID uuid.UUID, userID string, role Role) (Membership, error)

AddMember adds a user to the tenant with the given role. It returns a *ValidationError, ErrTenantNotFound or ErrMemberExists.

func (*Service) ByDomain

func (s *Service) ByDomain(ctx context.Context, domain string) (Tenant, error)

ByDomain returns the tenant that owns the given custom domain. A malformed domain yields ErrTenantNotFound, since no tenant can have it.

func (*Service) BySlug

func (s *Service) BySlug(ctx context.Context, slug string) (Tenant, error)

BySlug returns the tenant with the given slug. A malformed slug yields ErrTenantNotFound, since no tenant can have it.

func (*Service) Create

func (s *Service) Create(ctx context.Context, in CreateInput) (Tenant, error)

Create creates an active tenant. It returns a *ValidationError, ErrSlugTaken or ErrDomainTaken.

func (*Service) Get

func (s *Service) Get(ctx context.Context, id uuid.UUID) (Tenant, error)

Get returns the tenant with the given ID, or ErrTenantNotFound.

func (*Service) Invitation

func (s *Service) Invitation(ctx context.Context, tenantID, id uuid.UUID) (Invitation, error)

Invitation returns one of the tenant's invitations by ID, in any state, or ErrInvitationNotFound.

func (*Service) InvitationByToken

func (s *Service) InvitationByToken(ctx context.Context, token string) (Invitation, error)

InvitationByToken returns the invitation for a token in any state, so an application can show "you have been invited to ..." before acceptance; use Invitation.StatusAt to tell whether it can still be accepted. It returns ErrInvitationNotFound for malformed and unknown tokens alike.

Stores look the invitation up by the SHA-256 hash of the token, so lookup timing reveals nothing usable about the token itself; the hash the store returns is then compared in constant time, so a store that matched loosely (by prefix or case-insensitively) cannot let a wrong token in.

func (*Service) Invite

func (s *Service) Invite(ctx context.Context, tenantID uuid.UUID, in InviteInput) (Invitation, string, error)

Invite creates an invitation and returns it with its secret token. The token is not stored and cannot be recovered: deliver it to the invitee (typically inside a link) and discard it. Inviting an email that already has a pending invitation to the tenant replaces that invitation, so only the newest token works. It returns a *ValidationError or ErrTenantNotFound.

func (*Service) List

func (s *Service) List(ctx context.Context, opts ListOptions) (TenantPage, error)

List returns a page of tenants. Pagination is keyset-based, so pages stay consistent while tenants are being created. It returns a *ValidationError for an unknown status or a negative limit.

Example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/google/uuid"

	"github.com/byteminus/go-tenancy"
	"github.com/byteminus/go-tenancy/memstore"
)

func main() {
	ctx := context.Background()
	svc, err := tenancy.New(memstore.New(), tenancy.Options{})
	if err != nil {
		log.Fatal(err)
	}
	for _, slug := range []string{"one", "two", "three"} {
		if _, err := svc.Create(ctx, tenancy.CreateInput{Slug: slug, Name: slug}); err != nil {
			log.Fatal(err)
		}
	}
	opts := tenancy.ListOptions{Limit: 2}
	for {
		page, err := svc.List(ctx, opts)
		if err != nil {
			log.Fatal(err)
		}
		for _, t := range page.Tenants {
			fmt.Println(t.Slug)
		}
		if page.Next == uuid.Nil {
			break
		}
		opts.After = page.Next
	}
}
Output:
one
two
three

func (*Service) Member

func (s *Service) Member(ctx context.Context, tenantID uuid.UUID, userID string) (Membership, error)

Member returns the user's membership in the tenant, or ErrMemberNotFound (also for a malformed user ID).

func (*Service) Members

func (s *Service) Members(ctx context.Context, tenantID uuid.UUID, opts MemberListOptions) (MemberPage, error)

Members returns a page of the tenant's memberships. An unknown tenant yields an empty page. It returns a *ValidationError for a negative limit.

func (*Service) PendingInvitations

func (s *Service) PendingInvitations(ctx context.Context, tenantID uuid.UUID, opts InvitationListOptions) (InvitationPage, error)

PendingInvitations returns a page of the tenant's invitations that can still be accepted, oldest first. An unknown tenant yields an empty page. It returns a *ValidationError for a negative limit.

Example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/google/uuid"

	"github.com/byteminus/go-tenancy"
	"github.com/byteminus/go-tenancy/memstore"
)

func main() {
	ctx := context.Background()
	svc, err := tenancy.New(memstore.New(), tenancy.Options{})
	if err != nil {
		log.Fatal(err)
	}
	t, err := svc.Create(ctx, tenancy.CreateInput{Slug: "team", Name: "Team", Owner: "alice"})
	if err != nil {
		log.Fatal(err)
	}
	alice := tenancy.Ref{Type: "user", ID: "alice"}
	for _, email := range []string{"bob@example.com", "carol@example.com", "dave@example.com"} {
		if _, _, err := svc.Invite(ctx, t.ID, tenancy.InviteInput{Email: email, Role: tenancy.RoleMember, InvitedBy: alice}); err != nil {
			log.Fatal(err)
		}
	}
	opts := tenancy.InvitationListOptions{Limit: 2}
	for {
		page, err := svc.PendingInvitations(ctx, t.ID, opts)
		if err != nil {
			log.Fatal(err)
		}
		for _, inv := range page.Invitations {
			fmt.Println(inv.Email, "invited by", inv.InvitedBy)
		}
		if page.Next == uuid.Nil {
			break
		}
		opts.After = page.Next
	}
}
Output:
bob@example.com invited by user:alice
carol@example.com invited by user:alice
dave@example.com invited by user:alice

func (*Service) RemoveDomain

func (s *Service) RemoveDomain(ctx context.Context, id uuid.UUID, domain string) (Tenant, error)

RemoveDomain detaches a custom domain from the tenant. It returns a *ValidationError, ErrTenantNotFound or ErrDomainNotFound.

func (*Service) RemoveMember

func (s *Service) RemoveMember(ctx context.Context, tenantID uuid.UUID, userID string) error

RemoveMember removes a user from the tenant. It returns a *ValidationError, ErrMemberNotFound, or ErrLastOwner when it would remove the tenant's only owner.

func (*Service) RevokeInvitation

func (s *Service) RevokeInvitation(ctx context.Context, tenantID, id uuid.UUID) error

RevokeInvitation withdraws one of the tenant's pending or expired invitations so its token no longer works. It returns ErrInvitationNotFound, ErrInvitationAccepted or ErrInvitationRevoked.

func (*Service) SetRole

func (s *Service) SetRole(ctx context.Context, tenantID uuid.UUID, userID string, role Role) (Membership, error)

SetRole changes a member's role. It returns a *ValidationError, ErrMemberNotFound, or ErrLastOwner when it would demote the tenant's only owner.

Example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/byteminus/go-tenancy"
	"github.com/byteminus/go-tenancy/memstore"
)

func main() {
	ctx := context.Background()
	svc, err := tenancy.New(memstore.New(), tenancy.Options{})
	if err != nil {
		log.Fatal(err)
	}
	t, err := svc.Create(ctx, tenancy.CreateInput{Slug: "solo", Name: "Solo", Owner: "alice"})
	if err != nil {
		log.Fatal(err)
	}

	_, err = svc.SetRole(ctx, t.ID, "alice", tenancy.RoleMember)
	fmt.Println(err)

	if _, err := svc.AddMember(ctx, t.ID, "bob", tenancy.RoleOwner); err != nil {
		log.Fatal(err)
	}
	m, err := svc.SetRole(ctx, t.ID, "alice", tenancy.RoleMember)
	fmt.Println(m.Role, err)
}
Output:
tenancy: cannot remove or demote the last owner: tenancy: conflict
member <nil>

func (*Service) Suspend

func (s *Service) Suspend(ctx context.Context, id uuid.UUID) (Tenant, error)

Suspend marks the tenant suspended. Suspending a suspended tenant is not an error. It returns ErrTenantNotFound for an unknown tenant.

func (*Service) Update

func (s *Service) Update(ctx context.Context, id uuid.UUID, in UpdateInput) (Tenant, error)

Update changes the given fields of a tenant. With no fields set it returns the tenant unchanged. It returns a *ValidationError, ErrTenantNotFound or ErrSlugTaken.

func (*Service) UserMemberships

func (s *Service) UserMemberships(ctx context.Context, userID string, opts UserMembershipListOptions) (UserMembershipPage, error)

UserMemberships returns a page of the user's tenant memberships. It returns a *ValidationError for a malformed user ID or a negative limit.

Example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/byteminus/go-tenancy"
	"github.com/byteminus/go-tenancy/memstore"
)

func main() {
	ctx := context.Background()
	svc, err := tenancy.New(memstore.New(), tenancy.Options{})
	if err != nil {
		log.Fatal(err)
	}
	for _, slug := range []string{"first", "second"} {
		if _, err := svc.Create(ctx, tenancy.CreateInput{Slug: slug, Name: slug, Owner: "alice"}); err != nil {
			log.Fatal(err)
		}
	}
	page, err := svc.UserMemberships(ctx, "alice", tenancy.UserMembershipListOptions{})
	if err != nil {
		log.Fatal(err)
	}
	for _, m := range page.Memberships {
		t, err := svc.Get(ctx, m.TenantID)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println(t.Slug, m.Role)
	}
}
Output:
first owner
second owner

type Status

type Status string

Status is the lifecycle state of a tenant.

const (
	// StatusActive tenants serve requests normally.
	StatusActive Status = "active"
	// StatusSuspended tenants are kept intact but refused by the HTTP
	// middleware until they are activated again.
	StatusSuspended Status = "suspended"
)

func (Status) Valid

func (s Status) Valid() bool

Valid reports whether s is one of the defined statuses.

type Store

type Store interface {
	// CreateTenant inserts t together with its domains and, when owner is
	// not nil, the owner's membership, all or nothing. It returns
	// ErrSlugTaken or ErrDomainTaken on conflicts.
	CreateTenant(ctx context.Context, t Tenant, owner *Membership) error
	// Tenant returns the tenant with the given ID or ErrTenantNotFound.
	Tenant(ctx context.Context, id uuid.UUID) (Tenant, error)
	// TenantBySlug returns the tenant with the given slug or
	// ErrTenantNotFound.
	TenantBySlug(ctx context.Context, slug string) (Tenant, error)
	// TenantByDomain returns the tenant owning the given custom domain or
	// ErrTenantNotFound.
	TenantByDomain(ctx context.Context, domain string) (Tenant, error)
	// UpdateTenant applies the non-nil fields of c, sets UpdatedAt to now
	// and returns the updated tenant. It returns ErrTenantNotFound or
	// ErrSlugTaken.
	UpdateTenant(ctx context.Context, id uuid.UUID, c TenantChanges, now time.Time) (Tenant, error)
	// SetTenantStatus sets the status, sets UpdatedAt to now and returns the
	// updated tenant, or ErrTenantNotFound.
	SetTenantStatus(ctx context.Context, id uuid.UUID, status Status, now time.Time) (Tenant, error)
	// ListTenants returns up to q.Limit tenants with an ID greater than
	// q.After (all when q.After is uuid.Nil), ordered by ID, optionally
	// filtered by status.
	ListTenants(ctx context.Context, q TenantQuery) ([]Tenant, error)
	// AddDomain attaches domain to the tenant, sets UpdatedAt to now and
	// returns the updated tenant. It returns ErrTenantNotFound or
	// ErrDomainTaken.
	AddDomain(ctx context.Context, id uuid.UUID, domain string, now time.Time) (Tenant, error)
	// RemoveDomain detaches domain from the tenant, sets UpdatedAt to now
	// and returns the updated tenant. It returns ErrTenantNotFound, or
	// ErrDomainNotFound when the tenant does not have the domain.
	RemoveDomain(ctx context.Context, id uuid.UUID, domain string, now time.Time) (Tenant, error)

	// AddMember inserts m. It returns ErrTenantNotFound or ErrMemberExists.
	AddMember(ctx context.Context, m Membership) error
	// Member returns the membership or ErrMemberNotFound.
	Member(ctx context.Context, tenantID uuid.UUID, userID string) (Membership, error)
	// ListMembers returns up to q.Limit memberships of the tenant with a
	// user ID greater than q.After in byte order, ordered by user ID. An
	// unknown tenant yields an empty list.
	ListMembers(ctx context.Context, tenantID uuid.UUID, q MemberQuery) ([]Membership, error)
	// UserMemberships returns up to q.Limit memberships of the user with a
	// tenant ID greater than q.After (all when q.After is uuid.Nil),
	// ordered by tenant ID.
	UserMemberships(ctx context.Context, userID string, q UserMembershipQuery) ([]Membership, error)
	// SetMemberRole changes the member's role, sets UpdatedAt to now and
	// returns the updated membership. It returns ErrMemberNotFound, or
	// ErrLastOwner when it would demote the tenant's only owner. The check
	// and the write must be atomic with respect to every other
	// SetMemberRole and RemoveMember call on the same tenant.
	SetMemberRole(ctx context.Context, tenantID uuid.UUID, userID string, role Role, now time.Time) (Membership, error)
	// RemoveMember deletes the membership. It returns ErrMemberNotFound, or
	// ErrLastOwner when it would remove the tenant's only owner, with the
	// same atomicity as SetMemberRole.
	RemoveMember(ctx context.Context, tenantID uuid.UUID, userID string) error

	// PutInvitation inserts inv, atomically replacing any pending (neither
	// accepted nor revoked) invitation for the same tenant and email, and
	// returns the stored invitation. It returns ErrTenantNotFound.
	PutInvitation(ctx context.Context, inv Invitation) (Invitation, error)
	// Invitation returns the invitation of the tenant with the given ID, in
	// any state, or ErrInvitationNotFound.
	Invitation(ctx context.Context, tenantID, id uuid.UUID) (Invitation, error)
	// InvitationByTokenHash returns the invitation, in any state, or
	// ErrInvitationNotFound.
	InvitationByTokenHash(ctx context.Context, hash []byte) (Invitation, error)
	// AcceptInvitation atomically checks that the invitation is pending at
	// now, creates the membership with the invitation's role and marks the
	// invitation accepted by userID. It returns ErrInvitationNotFound,
	// ErrInvitationAccepted, ErrInvitationRevoked, ErrInvitationExpired or
	// ErrMemberExists, and changes nothing in those cases. Concurrent calls
	// for the same invitation succeed at most once.
	AcceptInvitation(ctx context.Context, hash []byte, userID string, now time.Time) (Membership, error)
	// RevokeInvitation marks a pending or expired invitation revoked at now.
	// It returns ErrInvitationNotFound, ErrInvitationAccepted or
	// ErrInvitationRevoked.
	RevokeInvitation(ctx context.Context, tenantID, id uuid.UUID, now time.Time) error
	// PendingInvitations returns up to q.Limit of the tenant's invitations
	// that are neither accepted nor revoked, expire after now and have an
	// ID greater than q.After (all when q.After is uuid.Nil), ordered by
	// ID.
	PendingInvitations(ctx context.Context, tenantID uuid.UUID, now time.Time, q InvitationQuery) ([]Invitation, error)
}

Store persists tenants, domains, memberships and invitations.

Store is implemented by storage backends (memstore and pgxstore in this module, or your own) and called only by Service. The Service validates and normalizes every input before calling a Store, so stores receive clean values and are responsible only for persistence, integrity (uniqueness, references) and the atomicity of the documented read-then-write operations. Implementations must be safe for concurrent use, must return an error wrapping ctx.Err() once ctx is done, must return times in UTC and must return the sentinel errors of this package. Every list method returns a non-nil slice. The storetest package verifies these rules.

type Tenant

type Tenant struct {
	// ID is a UUID v7 assigned on creation.
	ID uuid.UUID
	// Slug is the unique, lowercase, DNS-label-shaped handle of the tenant,
	// suitable as a subdomain or path segment.
	Slug string
	// Name is the human-readable display name.
	Name string
	// Status is StatusActive or StatusSuspended.
	Status Status
	// Domains are the tenant's custom domains, lowercase and sorted. Each
	// domain belongs to at most one tenant.
	Domains []string
	// Metadata is an application-defined JSON object. Stores may reformat
	// it (PostgreSQL jsonb normalizes whitespace and key order), so compare
	// it semantically rather than byte for byte.
	Metadata json.RawMessage
	// CreatedAt and UpdatedAt are UTC timestamps with microsecond precision.
	CreatedAt time.Time
	UpdatedAt time.Time
}

Tenant is one customer account of a multi-tenant application.

func TenantFrom added in v0.2.0

func TenantFrom(ctx context.Context) (Tenant, bool)

TenantFrom returns the tenant stored by WithTenant. It returns false when ctx carries no tenant.

func (Tenant) Active

func (t Tenant) Active() bool

Active reports whether the tenant is active.

func (Tenant) HasDomain

func (t Tenant) HasDomain(domain string) bool

HasDomain reports whether domain, compared case-insensitively, is one of the tenant's custom domains.

type TenantChanges

type TenantChanges struct {
	// Slug, when not nil, is the new normalized slug.
	Slug *string
	// Name, when not nil, is the new display name.
	Name *string
	// Metadata, when not nil, replaces the whole metadata object.
	Metadata json.RawMessage
}

TenantChanges lists the tenant fields to update; nil fields are left unchanged.

type TenantPage

type TenantPage struct {
	// Tenants holds at most the requested number of tenants.
	Tenants []Tenant
	// Next is the After value for the following page, or uuid.Nil when
	// this is the last page.
	Next uuid.UUID
}

TenantPage is one page of tenants ordered by ID (creation order, since IDs are UUID v7).

type TenantQuery

type TenantQuery struct {
	// Status filters by status when not empty.
	Status Status
	// After is the exclusive lower bound on the tenant ID.
	After uuid.UUID
	// Limit is the maximum number of tenants to return; always positive.
	Limit int
}

TenantQuery selects a page of tenants for Store.ListTenants.

type UpdateInput

type UpdateInput struct {
	// Slug, when not nil, is the new slug, normalized with NormalizeSlug.
	Slug *string
	// Name, when not nil, is the new display name.
	Name *string
	// Metadata replaces the whole metadata object when not nil.
	Metadata json.RawMessage
}

UpdateInput lists the tenant fields to change; nil fields are left as they are.

type UserMembershipListOptions added in v0.2.0

type UserMembershipListOptions struct {
	// After is the Next value of the previous page; uuid.Nil starts from
	// the beginning.
	After uuid.UUID
	// Limit is the page size: zero means DefaultPageSize, and values above
	// MaxPageSize are capped.
	Limit int
}

UserMembershipListOptions selects a page of a user's memberships.

type UserMembershipPage added in v0.2.0

type UserMembershipPage struct {
	// Memberships holds at most the requested number of memberships.
	Memberships []Membership
	// Next is the After value for the following page, or uuid.Nil when
	// this is the last page.
	Next uuid.UUID
}

UserMembershipPage is one page of a user's memberships ordered by tenant ID (tenant creation order, since IDs are UUID v7).

type UserMembershipQuery added in v0.2.0

type UserMembershipQuery struct {
	// After is the exclusive lower bound on the tenant ID.
	After uuid.UUID
	// Limit is the maximum number of memberships to return; always
	// positive.
	Limit int
}

UserMembershipQuery selects a page of memberships for Store.UserMemberships.

type ValidationError

type ValidationError struct {
	// Field names the input field, such as "slug" or "domain".
	Field string
	// Reason is a short human-readable explanation.
	Reason string
}

ValidationError reports input that fails validation. It matches ErrInvalid with errors.Is, and errors.As exposes the offending field.

func (*ValidationError) Error

func (e *ValidationError) Error() string

Error implements the error interface.

func (*ValidationError) Is

func (e *ValidationError) Is(target error) bool

Is reports whether target is ErrInvalid.

Directories

Path Synopsis
Package httptenant resolves the tenant of each HTTP request and stores it in the request context.
Package httptenant resolves the tenant of each HTTP request and stores it in the request context.
Package memstore is an in-memory tenancy.Store for tests, examples and prototypes.
Package memstore is an in-memory tenancy.Store for tests, examples and prototypes.
Package pgxstore is a PostgreSQL tenancy.Store built on pgx v5.
Package pgxstore is a PostgreSQL tenancy.Store built on pgx v5.
Package rls isolates tenants inside shared PostgreSQL tables with row-level security.
Package rls isolates tenants inside shared PostgreSQL tables with row-level security.
Package storetest is a conformance suite for tenancy.Store implementations.
Package storetest is a conformance suite for tenancy.Store implementations.

Jump to

Keyboard shortcuts

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