tenancy

package module
v0.1.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: 16 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.NewService(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})
	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) {
		t := tenancy.MustFromContext(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. Inputs are trimmed and lowercased before validation; 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. Create can make the first owner in the same atomic write.

Invitation. Invite returns the invitation and a 256-bit random token, once; the store keeps only its SHA-256 hash. 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. The package does not know users' email addresses; if the accepting user must own the invited address, check that before accepting.

Request tenant. WithTenant, FromContext and MustFromContext carry the tenant in a context.Context. 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. Host parsing strips ports, rejects IP literals and malformed hosts, and reads X-Forwarded-Host only when TrustForwardedHost is set, and then only a single unambiguous value. 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. With no tenant set, no rows are visible. Superusers and BYPASSRLS roles ignore policies: the application must connect as an ordinary role.

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)
);

CREATE INDEX IF NOT EXISTS "tenancy_memberships_user_id_idx" ON "tenancy_memberships" (user_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  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)
);

-- 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;

The last-owner guard locks the tenant row (SELECT ... FOR NO KEY UPDATE) before counting owners, invitation acceptance locks the invitation row, and re-invitation is a single upsert on the partial unique index, so these rules hold across processes.

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{}

store, err := 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
ddl, err := rls.Policy{Table: "app.invoices", Column: "account_id"}.EnableSQL()
// Run ddl in a migration; rls.Policy{...}.DisableSQL() is the Down step.

err = rls.InTenantTx(ctx, pool, tenant.ID, func(ctx context.Context, tx pgx.Tx) error {
	rows, err := tx.Query(ctx, `SELECT id, total FROM app.invoices`) // only this tenant's rows
	...
})

API overview

Package Contents
tenancy 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, Role, Status; Store interface; WithTenant, FromContext, MustFromContext; 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: ErrTenantNotFound, ErrSlugTaken, ErrDomainTaken, ErrDomainNotFound, ErrMemberNotFound, ErrMemberExists, ErrLastOwner, ErrInvitationNotFound, ErrInvitationExpired, ErrInvitationAccepted, ErrInvitationRevoked, and *ValidationError (matches ErrInvalid).

Testing

go test -race ./...
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/tenancy?sslmode=disable go test -race ./...

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 a temporary role named tenancy_test_<random> and switch to it with SET ROLE, so the database user must be allowed to create roles; the role and schema 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.
  • Cursor-paged listing of a user's memberships and of pending invitations.

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. Service.Create, Get, BySlug, ByDomain, Update, Suspend, Activate, List (keyset-paged), AddDomain and RemoveDomain manage them.

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.

Invitations

Service.Invite creates an invitation for an email address and returns a random token exactly once; only its SHA-256 hash is stored. Accepting it is single-use and creates the membership atomically. Invitations expire, can be revoked, and re-inviting an address replaces its pending invitation.

Request tenant

WithTenant, FromContext and MustFromContext carry the current tenant in a context.Context. The httptenant subpackage resolves it 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. 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.NewService(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: "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
)

Variables

View Source
var (
	// ErrInvalid is matched by every *ValidationError.
	ErrInvalid = errors.New("tenancy: invalid input")

	// ErrTenantNotFound is returned when a tenant does not exist.
	ErrTenantNotFound = errors.New("tenancy: tenant not found")
	// ErrSlugTaken is returned when another tenant already uses the slug.
	ErrSlugTaken = errors.New("tenancy: slug already taken")
	// ErrDomainTaken is returned when a domain already belongs to a tenant,
	// including the same tenant.
	ErrDomainTaken = errors.New("tenancy: domain already taken")
	// ErrDomainNotFound is returned when removing a domain the tenant does
	// not have.
	ErrDomainNotFound = errors.New("tenancy: domain not found")

	// ErrMemberNotFound is returned when the user is not a member of the
	// tenant (or the tenant does not exist).
	ErrMemberNotFound = errors.New("tenancy: member not found")
	// ErrMemberExists is returned when the user is already a member.
	ErrMemberExists = errors.New("tenancy: member already exists")
	// ErrLastOwner is returned when removing or demoting the only remaining
	// owner of a tenant.
	ErrLastOwner = errors.New("tenancy: cannot remove or demote the last owner")

	// ErrInvitationNotFound is returned for unknown invitations and for
	// tokens that are malformed or do not match any invitation.
	ErrInvitationNotFound = errors.New("tenancy: invitation not found")
	// ErrInvitationExpired is returned when accepting an expired invitation.
	ErrInvitationExpired = errors.New("tenancy: invitation expired")
	// ErrInvitationAccepted is returned when an invitation was already used.
	ErrInvitationAccepted = errors.New("tenancy: invitation already accepted")
	// ErrInvitationRevoked is returned when an invitation was revoked.
	ErrInvitationRevoked = errors.New("tenancy: invitation revoked")
)

Sentinel errors returned by the Service and by Store implementations. Stores must return these (possibly wrapped) so that callers can rely on errors.Is regardless of the backing store.

Functions

func NormalizeDomain

func NormalizeDomain(s string) (string, error)

NormalizeDomain lowercases and trims s, 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. 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 lowercases and trims s and checks that the result is a valid slug: 1 to 63 characters of a-z, 0-9 and '-', not starting or ending with '-'. It returns a *ValidationError otherwise.

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.FromContext(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 uuid.UUID
	// Email is lowercase.
	Email string
	Role  Role
	// TokenHash is the SHA-256 digest of the invitation token.
	TokenHash []byte
	// InvitedBy is the optional user ID of whoever sent the invitation.
	InvitedBy string
	CreatedAt time.Time
	ExpiresAt time.Time
	// AcceptedAt and AcceptedBy are set together when the invitation is
	// accepted.
	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 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 user ID of the sender.
	InvitedBy string
	// 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 []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 uuid.UUID
	// UserID identifies the user in the application's own user system.
	UserID string
	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. They are
	// compared after normalization.
	ReservedSlugs []string
}

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

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.

func NewService

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

NewService returns a Service backed by store.

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. The package does not know users' email addresses: when the invitee must be the owner of the invited address, check that before calling.

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.

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.

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.

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.

func (*Service) Get

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

Get returns the tenant with the given ID.

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.

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.

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.

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.

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.NewService(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.

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.

func (*Service) PendingInvitations

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

PendingInvitations returns the tenant's invitations that can still be accepted, oldest first.

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.

func (*Service) RemoveMember

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

RemoveMember removes a user from the tenant. Removing the tenant's only owner returns ErrLastOwner.

func (*Service) RevokeInvitation

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

RevokeInvitation withdraws one of the tenant's invitations so its token no longer works.

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. Demoting the tenant's only owner returns ErrLastOwner.

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.NewService(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
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.

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.

func (*Service) UserMemberships

func (s *Service) UserMemberships(ctx context.Context, userID string) ([]Membership, error)

UserMemberships returns every tenant membership of the user, oldest first.

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 every membership of the user, ordered by
	// creation time.
	UserMemberships(ctx context.Context, userID string) ([]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 the tenant's invitations that are neither
	// accepted nor revoked and expire after now, ordered by creation time.
	PendingInvitations(ctx context.Context, tenantID uuid.UUID, now time.Time) ([]Invitation, error)
}

Store persists tenants, domains, memberships and invitations. 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 and return the sentinel errors of this package. 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 FromContext

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

FromContext returns the tenant stored by WithTenant and whether one was present.

func MustFromContext

func MustFromContext(ctx context.Context) Tenant

MustFromContext returns the tenant stored by WithTenant. It panics when ctx carries no tenant: use it only in code that runs behind the tenant middleware, where a missing tenant is a programming error.

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     *string
	Name     *string
	Metadata json.RawMessage
}

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

type TenantPage

type TenantPage struct {
	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 *string
	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 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