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 ¶
- Constants
- Variables
- func NormalizeDomain(s string) (string, error)
- func NormalizeSlug(s string) (string, error)
- func WithTenant(ctx context.Context, t Tenant) context.Context
- type CreateInput
- type Invitation
- type InvitationStatus
- type InviteInput
- type ListOptions
- type MemberListOptions
- type MemberPage
- type MemberQuery
- type Membership
- type Options
- 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) ([]Invitation, 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) ([]Membership, error)
- type Status
- type Store
- type Tenant
- type TenantChanges
- type TenantPage
- type TenantQuery
- type UpdateInput
- 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 )
Variables ¶
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 ¶
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 ¶
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 ¶
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 ¶
NewService returns a Service backed by store.
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. 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 ¶
Activate marks the tenant active. Activating an active tenant is not an error.
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 ¶
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) Invitation ¶
Invitation returns one of the tenant's invitations by ID, in any state.
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.
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 ¶
PendingInvitations returns the tenant's invitations that can still be accepted, oldest first.
func (*Service) RemoveDomain ¶
RemoveDomain detaches a custom domain from the tenant.
func (*Service) RemoveMember ¶
RemoveMember removes a user from the tenant. Removing the tenant's only owner returns ErrLastOwner.
func (*Service) RevokeInvitation ¶
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 ¶
Suspend marks the tenant suspended. Suspending a suspended tenant is not an error.
func (*Service) Update ¶
Update changes the given fields of a tenant. With no fields set it returns the tenant unchanged.
func (*Service) UserMemberships ¶
UserMemberships returns every tenant membership of the user, oldest first.
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 ¶
FromContext returns the tenant stored by WithTenant and whether one was present.
func MustFromContext ¶
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.
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.
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. |