Documentation
¶
Overview ¶
Package metadata keeps three JSON documents per user, the shape Clerk made familiar:
- public: the user reads it, only the backend writes it. A plan, a role label, an onboarding step the server decided.
- private: only the backend reads and writes it. A billing customer id, an internal note. No operation a user can call ever answers it.
- unsafe: the user reads and writes it, through a JSON merge patch (RFC 7396), capped in size. UI preferences, a dismissed banner. The name is the warning: never trust it for a decision.
The documents live in a table of the plugin's own, one row per user, rather than as columns on auth_user, for two reasons. A column extension is a short value (VARCHAR(320) on MySQL), too small for a document; and every auth_user column is loaded with the user, so a private document there would travel with every request's user, one careless encode away from a browser. Here it is read only when asked for.
Every document is a JSON object; anything else is refused, so a client can always read a key without first checking the type. A user with no row reads as three empty objects.
Index ¶
- Constants
- Variables
- type AccessRequest
- type Action
- type AdminGetRequest
- type AdminUpdateRequest
- type Authorizer
- type AuthorizerFunc
- type Kind
- type Metadata
- type Option
- type Plugin
- func (p *Plugin) Get(ctx context.Context, userID string) (Metadata, error)
- func (p *Plugin) Init(a *auth.Auth) error
- func (p *Plugin) Merge(ctx context.Context, userID string, kind Kind, patch json.RawMessage) (json.RawMessage, error)
- func (p *Plugin) Name() string
- func (p *Plugin) Operations() []auth.Operation
- func (p *Plugin) OptionErr() error
- func (p *Plugin) PublicOf(ctx context.Context, userID string) (json.RawMessage, error)
- func (p *Plugin) RateLimits() []auth.RateRule
- func (p *Plugin) SessionExtras(ctx context.Context, c *auth.Call) (any, error)
- func (p *Plugin) Set(ctx context.Context, userID string, kind Kind, doc json.RawMessage) error
- func (p *Plugin) Tables() []sqlb.Table
- func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)
- func (p *Plugin) UserTables() []auth.UserTable
- type UnsafeCheck
- type UpdateRequest
- type UserMetadata
Constants ¶
const ( // EndpointGet answers the signed-in user's public and unsafe documents. //ds:def id=metadata-endpointget-4kq9wm2x owner=@khanakia stability=stable EndpointGet auth.EndpointID = "metadata.get" // EndpointUpdate merges a patch into the signed-in user's unsafe // document. //ds:def id=metadata-endpointupdate-7nr3xk5q owner=@khanakia stability=stable EndpointUpdate auth.EndpointID = "metadata.update" // EndpointAdminGet answers all three documents of any user, for a caller // the Authorizer allows. EndpointAdminGet auth.EndpointID = "metadata.admin-get" // EndpointAdminUpdate merges patches into any of a user's three // documents, for a caller the Authorizer allows. EndpointAdminUpdate auth.EndpointID = "metadata.admin-update" )
Operation ids. The two user operations are always served; the admin pair only under WithAdmin.
const ( // DefaultMaxSize bounds the public and the private document unless // WithMaxSize changes it. 8 KiB is Clerk's bound on a user's metadata, // and public travels with every session answer that includes it. //ds:def id=metadata-defaultmaxsize-3wq8kn6v owner=@khanakia stability=stable DefaultMaxSize = 8 << 10 // DefaultUnsafeMaxSize bounds the unsafe document unless // WithUnsafeMaxSize changes it: smaller, because any user fills it. //ds:def id=metadata-defaultunsafemaxsize-9mk2wr4t owner=@khanakia stability=stable DefaultUnsafeMaxSize = 4 << 10 // MaxSizeLimit is the largest bound an option accepts: authhttp refuses // a request body over 1 MiB, so a larger document could be stored from // Go but never sent back through an update. MaxSizeLimit = 1 << 20 )
Size bounds, in bytes of the compact JSON encoding.
const ( DefaultUpdateLimit = 30 DefaultUpdateWindow = time.Minute )
The update rate a user gets by default (WithUpdateRate): enough for a settings page saved on every change, too few to use the table as a store.
const ( //ds:def id=metadata-tablename-6vq3kx8w owner=@khanakia stability=stable TableNameMetadata = "auth_user_metadata" TableAliasMetadata = "umd" ColUserID = "user_id" ColPublic = "public_metadata" ColPrivate = "private_metadata" ColUnsafe = "unsafe_metadata" ColUpdatedAt = "updated_at" )
The documents table: one row per user that was ever written. There is no foreign key (ADR-0002); DeleteUser removes the row through UserTables.
const PluginName = "metadata"
PluginName is the plugin's permanent identity: migration and table owner.
Variables ¶
var ( TableMetadata = sqlb.Table{Name: TableNameMetadata, Alias: TableAliasMetadata} MetaUserID = sqlb.Col[string](TableMetadata, ColUserID) MetaPublic = sqlb.Col[string](TableMetadata, ColPublic) MetaPrivate = sqlb.Col[string](TableMetadata, ColPrivate) MetaUnsafe = sqlb.Col[string](TableMetadata, ColUnsafe) MetaUpdatedAt = sqlb.Col[time.Time](TableMetadata, ColUpdatedAt) )
TableMetadata and its typed columns, for an app that reads or joins the documents (reads are open; only the plugin writes).
var ( // ErrInvalid refuses a document or patch that is not a JSON object. ErrInvalid = auth.NewError("metadata.invalid", http.StatusBadRequest, "Metadata must be a JSON object.") // ErrTooLarge refuses a document, or a patch, over the kind's bound. ErrTooLarge = auth.NewError("metadata.too_large", http.StatusRequestEntityTooLarge, "The metadata is too large.") // ErrForbidden answers an admin operation the Authorizer did not allow. ErrForbidden = auth.NewError("metadata.forbidden", http.StatusForbidden, "You may not manage this user's metadata.") // ErrUnknownKind is a programming error from the Go API: a Kind outside // KindValues. ErrUnknownKind = errors.New("metadata: unknown kind") )
Errors. The coded ones reach a client.
var ActionValues = []Action{ActionRead, ActionWrite}
ActionValues is the canonical iteration order over actions.
var KindValues = []Kind{Public, Private, Unsafe}
KindValues is the canonical iteration order over kinds.
Functions ¶
This section is empty.
Types ¶
type AccessRequest ¶
type AccessRequest struct {
// CallerID is the signed-in user asking.
CallerID string
// TargetUserID is the user whose documents the request names, as the
// input gave it: the decision comes before the user is looked up, so a
// refusal never reveals whether the user exists.
TargetUserID string
Action Action
// Call is the operation's call (session, client IP, decoded input), for
// an attribute-based rule. Read it; never change it.
Call *auth.Call
}
AccessRequest is one decision: may the caller take Action on the documents of TargetUserID?
type AdminGetRequest ¶
type AdminGetRequest struct {
UserID string `json:"userId"`
}
AdminGetRequest is the input of metadata.admin-get.
type AdminUpdateRequest ¶
type AdminUpdateRequest struct {
UserID string `json:"userId"`
Public json.RawMessage `json:"public,omitempty"`
Private json.RawMessage `json:"private,omitempty"`
Unsafe json.RawMessage `json:"unsafe,omitempty"`
}
AdminUpdateRequest is the input of metadata.admin-update: a merge patch per document; a document whose patch is absent is left as it is.
type Authorizer ¶
type Authorizer interface {
Authorize(ctx context.Context, req AccessRequest) (bool, error)
}
Authorizer decides an AccessRequest. An error refuses: the plugin never reads the boolean of a call that failed, so a policy engine that is down fails closed. It must be safe for concurrent use.
type AuthorizerFunc ¶
type AuthorizerFunc func(ctx context.Context, req AccessRequest) (bool, error)
AuthorizerFunc adapts a function to Authorizer.
func (AuthorizerFunc) Authorize ¶
func (f AuthorizerFunc) Authorize(ctx context.Context, req AccessRequest) (bool, error)
Authorize calls f.
type Metadata ¶
type Metadata struct {
Public json.RawMessage `json:"public"`
Private json.RawMessage `json:"private"`
Unsafe json.RawMessage `json:"unsafe"`
}
Metadata is all three documents of a user. Only the Go API and the admin operations return it; a user's own operations answer UserMetadata, which has no Private field to fill by mistake.
type Option ¶
type Option func(*Plugin)
Option configures the plugin; options are applied in order by New.
func WithAdmin ¶
func WithAdmin(a Authorizer) Option
WithAdmin serves metadata.admin-get and metadata.admin-update, each call decided by a. Without it neither route exists: the backend writes public and private through the Go API only. Wire it to plugin/admin with an AuthorizerFunc that calls admin's HasPermission, or to any other model.
func WithMaxSize ¶
WithMaxSize bounds the public and the private document, in bytes of compact JSON, between 2 and MaxSizeLimit. Default DefaultMaxSize.
func WithUnsafeCheck ¶
func WithUnsafeCheck(fn UnsafeCheck) Option
WithUnsafeCheck runs fn on every unsafe document a user's update would store; see UnsafeCheck. It does not run for the Go API or the admin operations, which are trusted.
func WithUnsafeMaxSize ¶
WithUnsafeMaxSize bounds the unsafe document, and every patch a user sends, in bytes of compact JSON, between 2 and MaxSizeLimit. Default DefaultUnsafeMaxSize.
type Plugin ¶
type Plugin struct {
// contains filtered or unexported fields
}
Plugin keeps the three documents. Build it with New.
func (*Plugin) Get ¶
Get returns all three documents of userID; a user with no row reads as three empty objects, and an unknown user is auth.ErrUserNotFound.
func (*Plugin) Merge ¶
func (p *Plugin) Merge(ctx context.Context, userID string, kind Kind, patch json.RawMessage) (json.RawMessage, error)
Merge applies patch (RFC 7396) to document kind of userID and returns the document stored. The patch must be a JSON object; the result is bounded by the kind's size.
func (*Plugin) Operations ¶
Operations lists the user operations, and the admin pair under WithAdmin.
func (*Plugin) PublicOf ¶
PublicOf returns userID's public document alone, for a JWT claim or a server-rendered page.
func (*Plugin) RateLimits ¶
RateLimits counts metadata.update per user (WithUpdateRate).
func (*Plugin) SessionExtras ¶
SessionExtras puts the user's public and unsafe documents in the session answer (GET /session) as an UserMetadata: pass it to auth.WithSessionExtras, or call it from the app's own extras function and nest the result. Private is never read by it.
func (*Plugin) Set ¶
Set replaces document kind of userID with doc, which must be a JSON object within the kind's bound. A nil or empty doc stores {}.
func (*Plugin) Tables ¶
Tables declares the documents table, so New can check its alias and an app can rename it.
func (*Plugin) TypedMigrations ¶
TypedMigrations creates the table. The documents take the engine's Doc type (TEXT, MEDIUMTEXT on MySQL), which has no default on MySQL, so every insert writes all three.
func (*Plugin) UserTables ¶
UserTables tells DeleteUser which row is a user's.
type UnsafeCheck ¶
UnsafeCheck inspects the unsafe document a user's update would store, after the merge and before the write, for an app that allows only some keys or shapes; an error refuses the update and is answered as returned, so return an *auth.Error to give the client a code.
type UpdateRequest ¶
type UpdateRequest struct {
Unsafe json.RawMessage `json:"unsafe"`
}
UpdateRequest is the input of metadata.update: a merge patch for the unsafe document (RFC 7396). A key set to null is removed; an object merges into the object under its key; anything else replaces the key's value. The input has no field for the other documents, and authhttp refuses unknown fields, so a user cannot even name them.
type UserMetadata ¶
type UserMetadata struct {
Public json.RawMessage `json:"public"`
Unsafe json.RawMessage `json:"unsafe"`
}
UserMetadata is what the user may see: public and unsafe. It is also what SessionExtras puts in the session answer.