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

tenancy

package
v0.6.7 Latest Latest
Warning

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

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 5 Imported by: 0

Documentation

Overview

Package tenancy carries who is making a request.

Every generated query is scoped by tenant and every generated write is stamped with an actor, so the claims are not optional context — they are an argument the repository cannot work without. Requiring them explicitly is what makes a missing scope a failure rather than a leak.

Index

Examples

Constants

View Source
const ScopeParam = "scope"

ScopeParam is the query-string key. Reserved: a column projecting to this name is a compile error, because two things answering to one parameter is a bug nobody would find from the outside.

Variables

This section is empty.

Functions

func Extra

func Extra[T any](c Claims) (T, error)

Extra decodes a caller's session context into the application's own type.

A function rather than a type parameter on Claims. A generic Claims[T] reads better in isolation and is the wrong trade here: generated code names tenancy.Claims in every handler, every service interface and every repository method, and the request envelope already carries three type parameters. One generic function gives the same type safety and costs nothing to ignore.

A caller with no payload gets the zero value and no error, which is the ordinary case rather than a failure: a session issued before the application started setting one, or a request from an API key, both land here.

Example

Extra reads the application's own session context out of the claims. It is a function rather than a type parameter on Claims, so generated code can name tenancy.Claims everywhere without threading a type through it.

package main

import (
	"encoding/json"
	"fmt"

	"github.com/google/uuid"

	"github.com/simonjanss/rig/runtime/tenancy"
)

var tenantID = uuid.MustParse("018f2c9e-4a1b-7c3d-9e5f-000000000001")

func main() {
	type sessionContext struct {
		Region string `json:"region"`
		Plan   string `json:"plan"`
	}

	payload, _ := json.Marshal(sessionContext{Region: "eu-north-1", Plan: "team"})
	c := tenancy.Claims{TenantID: tenantID, Extra: payload}

	ctx, err := tenancy.Extra[sessionContext](c)
	fmt.Println(ctx.Region, ctx.Plan, err)

	// A caller with no payload gets the zero value and no error — an API key, or
	// a session issued before the application started setting one.
	empty, err := tenancy.Extra[sessionContext](tenancy.Claims{TenantID: tenantID})
	fmt.Printf("%+v %v\n", empty, err)

}
Output:
eu-north-1 team <nil>
{Region: Plan:} <nil>

func NewContext

func NewContext(ctx context.Context, c Claims) context.Context

NewContext returns a context carrying the claims.

func Require

func Require(c Claims, permission string) error

Require refuses a caller who does not hold a permission.

It lives here rather than in the auth module because generated handlers call it: a project with no authentication at all still gets the generated check, and making that check depend on the auth module would make every project depend on argon2 and OAuth to serve a table of notes.

This is the whole of rig's authorization model — a set of strings on the claims and a membership test. Where the strings come from is the application's, and there is no interface to implement: something fills in Claims.Permissions before a handler runs. rig derives the *keys* from the schema so that the check cannot be forgotten, and stops there, because who holds which is a product decision no framework gets right for everybody.

The message names the permission. A caller who legitimately lacks it needs to know what to ask their administrator for, and one who does not learns nothing they could not read in the API documentation.

Example

Every generated handler asks this before it does anything, so a missing permission is a refusal rather than a query that quietly runs. The message names the permission, because a caller who legitimately lacks it needs to know what to ask their administrator for.

package main

import (
	"fmt"

	"github.com/google/uuid"

	"github.com/simonjanss/rig/runtime/tenancy"
)

var (
	tenantID  = uuid.MustParse("018f2c9e-4a1b-7c3d-9e5f-000000000001")
	accountID = uuid.MustParse("018f2c9e-4a1b-7c3d-9e5f-000000000002")
)

func main() {
	c := tenancy.Claims{
		TenantID:    tenantID,
		AccountID:   accountID,
		Permissions: []string{"todo:read"},
	}

	fmt.Println(tenancy.Require(c, "todo:read"))
	fmt.Println(tenancy.Require(c, "todo:delete"))
	fmt.Println(tenancy.Require(tenancy.Claims{}, "todo:read"))

}
Output:
<nil>
Forbidden: this action requires the "todo:delete" permission
Unauthorized: this request is not authenticated
Example (NoPermission)

An endpoint with no permission needs only a valid caller — which is what `public:` produces, and what a project that turned derivation off has.

package main

import (
	"fmt"

	"github.com/google/uuid"

	"github.com/simonjanss/rig/runtime/tenancy"
)

var (
	tenantID  = uuid.MustParse("018f2c9e-4a1b-7c3d-9e5f-000000000001")
	accountID = uuid.MustParse("018f2c9e-4a1b-7c3d-9e5f-000000000002")
)

func main() {
	c := tenancy.Claims{TenantID: tenantID, AccountID: accountID}

	fmt.Println(tenancy.Require(c, ""))

}
Output:
<nil>

func RequireScope

func RequireScope(c Claims, s Scope, wide string) error

RequireScope refuses a caller who asked to see more than they hold.

The wide permission is checked in addition to the endpoint's own, not instead of it: a generated handler calls Require with the read permission first and this second, so widening presupposes being allowed to read at all. A credential minted with only the wide key can therefore read nothing — which is the right shape (one grant, one thing) and worth knowing before minting one.

Gated on a permission rather than on the caller being a machine. The two are not the same thing and conflating them breaks both ways: a person's own API key acts as that person and must not see the whole tenant just because it is a key, and a human administrator reviewing everybody's rows must be able to. What makes a service account wide is that its key was minted with the permission — which is a decision somebody made and can revoke, rather than a property of how the request arrived.

The refusal is loud. Narrowing the answer instead would be the tempting move and it is the wrong one: a caller that asked for everything and got a page of its own rows has no way to tell that from there being nothing else, so it draws a wrong conclusion from a correct-looking response. Insufficient authority must never produce a smaller result set.

Types

type Claims

type Claims struct {
	TenantID  uuid.UUID
	AccountID uuid.UUID
	Subject   Subject

	// Roles and Permissions are what the caller may do, filled in by the
	// application before a handler runs — there is no model behind them here.
	// Permissions is what [Require] tests; Roles is for a coarse question an
	// application wants to ask itself.
	Roles       []string
	Permissions []string

	// ImpersonatedByAccountID is set when an administrator is acting as
	// someone else. It propagates through every audit entry, so the record
	// says who really did it.
	ImpersonatedByAccountID *uuid.UUID

	// Extra is the application's own context for this session, carried on the
	// credential and refreshed with it. Nil for a request that has none.
	//
	// Opaque here: the runtime stores and passes it and never looks inside.
	// [Extra] is the typed way to read it.
	//
	// It is only as fresh as the last refresh — up to the refresh lifetime, twelve
	// hours by default and thirty days for "remember me" — so nothing that decides
	// what a caller may do belongs in it. Permissions are resolved per request for
	// exactly that reason, and putting one here would mean revoking it took half a
	// day to bite.
	Extra json.RawMessage

	// APIKeyID is the key the request arrived with, when it arrived with one.
	//
	// It is separate from AccountID rather than folded into it because they
	// answer different questions: the account is whose change this is — a
	// service account for an integration, a person for their own key — and this
	// is which credential it came through. One service account can have several
	// keys, and when something has gone wrong the useful question is which key
	// to revoke.
	APIKeyID *uuid.UUID
}

Claims describe the caller.

func FromContext

func FromContext(ctx context.Context) (Claims, error)

FromContext returns the claims on a context.

The error is deliberate rather than a zero value: a repository that silently proceeded with an empty tenant would return every tenant's rows, and a bug that leaks data across tenants should be impossible to write by forgetting something.

func System

func System(tenantID uuid.UUID) Claims

System builds claims for rig's own work — migrations, background jobs — where there is no caller but a tenant still has to be named.

func (Claims) Actor

func (c Claims) Actor() *uuid.UUID

Actor is the account to stamp on a write, or nil when there is no person behind the request.

func (Claims) ActorKey

func (c Claims) ActorKey() *uuid.UUID

ActorKey is the key to stamp an audit column with, or nil for a request that did not come through one.

func (Claims) Can

func (c Claims) Can(permission string) bool

Can reports whether the caller holds a permission.

func (Claims) HasRole

func (c Claims) HasRole(role string) bool

HasRole reports whether the caller holds a role.

func (Claims) Valid

func (c Claims) Valid() bool

Valid reports whether the claims can scope a query. A tenant is the minimum: without one there is nothing to filter by, and running the query anyway would return every tenant's rows.

Example

Claims carrying no tenant cannot scope a query, and running one anyway would return every tenant's rows. That is why a repository takes claims as an argument rather than reading them from somewhere optional.

package main

import (
	"fmt"

	"github.com/google/uuid"

	"github.com/simonjanss/rig/runtime/tenancy"
)

var (
	tenantID  = uuid.MustParse("018f2c9e-4a1b-7c3d-9e5f-000000000001")
	accountID = uuid.MustParse("018f2c9e-4a1b-7c3d-9e5f-000000000002")
)

func main() {
	fmt.Println(tenancy.Claims{TenantID: tenantID}.Valid())
	fmt.Println(tenancy.Claims{AccountID: accountID}.Valid())

}
Output:
true
false

type Permission

type Permission struct {
	// Key is what a check looks for and what a role grants.
	Key string
	// Name and Description are for the screen where somebody hands it out. They
	// come from the resource, so nobody writes them twice.
	Name        string
	Description string
}

Permission is one thing a caller may be allowed to do.

It is here, in the runtime, because both ends need it and neither should have to import the other: generated code produces the list, and the authentication foundation reconciles the database against it.

type Scope

type Scope string

Scope is how wide a read reaches inside the caller's tenant.

It exists because the alternative is worse. A resource where people should normally see their own rows has two obvious shapes: one endpoint that quietly returns different sets to different callers, or two endpoints — /notes and /notes/all — where an integration has to be told which one to call and a client written against the narrow one silently keeps working when its credential is widened. Both hide the width of the answer. A parameter makes it part of the request, so the caller says what it wants, the response says what it got, and asking for more than you hold is a refusal rather than a shrug.

const (
	// ScopeOwn is rows the caller created. The default, so a request that says
	// nothing gets the narrow answer.
	ScopeOwn Scope = "own"
	// ScopeAll is every row in the tenant. It has to be asked for, and it has to
	// be held.
	ScopeAll Scope = "all"
)

func ParseScope

func ParseScope(raw string) (Scope, error)

ParseScope reads the parameter.

An unrecognised value is a 400 rather than a silent fallback to the narrow default: a typo that returns fewer rows than the caller expected is a bug that looks like missing data, and hunting one of those from the outside is miserable.

type Subject

type Subject string

Subject is what kind of caller a set of claims describes.

const (
	// SubjectAccount is a person, acting through a session.
	SubjectAccount Subject = "Account"
	// SubjectAPIKey is a machine, acting through a key.
	SubjectAPIKey Subject = "ApiKey"
	// SubjectSystem is rig itself: migrations, background work, and anything
	// else with no caller behind it.
	SubjectSystem Subject = "System"
)

Jump to

Keyboard shortcuts

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