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 ¶
- Constants
- Variables
- func NormalizeDomain(s string) (string, error)
- func NormalizeSlug(s string) (string, error)
- func TenantIDFrom(ctx context.Context) (string, bool)
- func WithTenant(ctx context.Context, t Tenant) context.Context
- type CreateInput
- type Invitation
- type InvitationListOptions
- type InvitationPage
- type InvitationQuery
- type InvitationStatus
- type InviteInput
- type ListOptions
- type MemberListOptions
- type MemberPage
- type MemberQuery
- type Membership
- type Options
- type Ref
- type Role
- type Service
- func (s *Service) AcceptInvitation(ctx context.Context, token, userID string) (Membership, error)
- func (s *Service) Activate(ctx context.Context, id uuid.UUID) (Tenant, error)
- func (s *Service) AddDomain(ctx context.Context, id uuid.UUID, domain string) (Tenant, error)
- func (s *Service) AddMember(ctx context.Context, tenantID uuid.UUID, userID string, role Role) (Membership, error)
- func (s *Service) ByDomain(ctx context.Context, domain string) (Tenant, error)
- func (s *Service) BySlug(ctx context.Context, slug string) (Tenant, error)
- func (s *Service) Create(ctx context.Context, in CreateInput) (Tenant, error)
- func (s *Service) Get(ctx context.Context, id uuid.UUID) (Tenant, error)
- func (s *Service) Invitation(ctx context.Context, tenantID, id uuid.UUID) (Invitation, error)
- func (s *Service) InvitationByToken(ctx context.Context, token string) (Invitation, error)
- func (s *Service) Invite(ctx context.Context, tenantID uuid.UUID, in InviteInput) (Invitation, string, error)
- func (s *Service) List(ctx context.Context, opts ListOptions) (TenantPage, error)
- func (s *Service) Member(ctx context.Context, tenantID uuid.UUID, userID string) (Membership, error)
- func (s *Service) Members(ctx context.Context, tenantID uuid.UUID, opts MemberListOptions) (MemberPage, error)
- func (s *Service) PendingInvitations(ctx context.Context, tenantID uuid.UUID, opts InvitationListOptions) (InvitationPage, error)
- func (s *Service) RemoveDomain(ctx context.Context, id uuid.UUID, domain string) (Tenant, error)
- func (s *Service) RemoveMember(ctx context.Context, tenantID uuid.UUID, userID string) error
- func (s *Service) RevokeInvitation(ctx context.Context, tenantID, id uuid.UUID) error
- func (s *Service) SetRole(ctx context.Context, tenantID uuid.UUID, userID string, role Role) (Membership, error)
- func (s *Service) Suspend(ctx context.Context, id uuid.UUID) (Tenant, error)
- func (s *Service) Update(ctx context.Context, id uuid.UUID, in UpdateInput) (Tenant, error)
- func (s *Service) UserMemberships(ctx context.Context, userID string, opts UserMembershipListOptions) (UserMembershipPage, error)
- type Status
- type Store
- type Tenant
- type TenantChanges
- type TenantPage
- type TenantQuery
- type UpdateInput
- type UserMembershipListOptions
- type UserMembershipPage
- type UserMembershipQuery
- type ValidationError
Examples ¶
Constants ¶
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 )
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 ¶
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.
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 ¶
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 ¶
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
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 ¶
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.
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
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 ¶
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 ¶
Activate marks the tenant active. Activating an active tenant is not an error. It returns ErrTenantNotFound for an unknown tenant.
func (*Service) AddDomain ¶
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 ¶
ByDomain returns the tenant that owns the given custom domain. A malformed domain yields ErrTenantNotFound, since no tenant can have it.
func (*Service) BySlug ¶
BySlug returns the tenant with the given slug. A malformed slug yields ErrTenantNotFound, since no tenant can have it.
func (*Service) Create ¶
Create creates an active tenant. It returns a *ValidationError, ErrSlugTaken or ErrDomainTaken.
func (*Service) Invitation ¶
Invitation returns one of the tenant's invitations by ID, in any state, or ErrInvitationNotFound.
func (*Service) InvitationByToken ¶
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 ¶
RemoveDomain detaches a custom domain from the tenant. It returns a *ValidationError, ErrTenantNotFound or ErrDomainNotFound.
func (*Service) RemoveMember ¶
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 ¶
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 ¶
Suspend marks the tenant suspended. Suspending a suspended tenant is not an error. It returns ErrTenantNotFound for an unknown tenant.
func (*Service) Update ¶
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 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
TenantFrom returns the tenant stored by WithTenant. It returns false when ctx carries no tenant.
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.
Source Files
¶
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. |