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 ¶
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 ¶
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 ¶
NewContext returns a context carrying the claims.
func Require ¶
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 ¶
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 ¶
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 ¶
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 ¶
Actor is the account to stamp on a write, or nil when there is no person behind the request.
func (Claims) ActorKey ¶
ActorKey is the key to stamp an audit column with, or nil for a request that did not come through one.
func (Claims) Valid ¶
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.
func ParseScope ¶
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" )