metadata

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

README

github.com/ubgo/auth/plugin/metadata

Three JSON documents per user: public (the user reads it, only the backend writes it), private (the backend's alone, never in any answer a user can get) and unsafe (the user reads it and writes it with a JSON merge patch, size-capped and rate-limited). Optional admin routes read and patch all three, each call decided by an Authorizer you supply.

Install

go get github.com/ubgo/auth

The package is in the core module github.com/ubgo/auth, so it brings no extra dependency beyond github.com/ubgo/sqlb.

Wire it

Taken from examples/user-metadata:

ad := admin.New()
md := metadata.New(metadata.WithAdmin(adminAuthorizer(ad)))
a, err := auth.New(
	auth.WithDB(db, sqlb.SQLiteDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithPlugins(email.New(email.Login(), email.Recover(), email.WithMailer(m)), password.New(), ad, md),
	auth.WithSessionExtras(md.SessionExtras),
)
// then: a.Migrate(ctx), and mount authhttp.Handler(a, authhttp.Prefix("/auth"))

md.Merge(ctx, userID, metadata.Public, json.RawMessage(`{"plan":"pro"}`))

metadata.New() with no option serves metadata.get and metadata.update only; WithAdmin adds metadata.admin-get and metadata.admin-update. The plugin imports no other plugin: the example's adminAuthorizer bridges to the admin plugin's roles in a few lines.

Storage

One table, auth_user_metadata, one row per user that was written, the documents in the engine's document type. A user with no row reads as {} three times; DeleteUser removes the row.

Guide

docs/plugins/metadata.md

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

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
const PluginName = "metadata"

PluginName is the plugin's permanent identity: migration and table owner.

Variables

TableMetadata and its typed columns, for an app that reads or joins the documents (reads are open; only the plugin writes).

View Source
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.

View Source
var ActionValues = []Action{ActionRead, ActionWrite}

ActionValues is the canonical iteration order over actions.

View Source
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 Action

type Action string

Action is what an admin operation does with the documents.

const (
	// ActionRead reads all three documents (metadata.admin-get).
	ActionRead Action = "read"
	// ActionWrite patches any of them (metadata.admin-update).
	ActionWrite Action = "write"
)

Actions.

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 Kind

type Kind string

Kind names one of the three documents.

const (
	// Public is readable by the user, writable by the backend only.
	Public Kind = "public"
	// Private is the backend's alone.
	Private Kind = "private"
	// Unsafe is the user's to read and write.
	Unsafe Kind = "unsafe"
)

The three documents.

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

func WithMaxSize(bytes int) Option

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

func WithUnsafeMaxSize(bytes int) Option

WithUnsafeMaxSize bounds the unsafe document, and every patch a user sends, in bytes of compact JSON, between 2 and MaxSizeLimit. Default DefaultUnsafeMaxSize.

func WithUpdateRate

func WithUpdateRate(limit int, window time.Duration) Option

WithUpdateRate limits metadata.update to limit calls per user per window, through the core's rate limiter (the app's RateStore counts it). Default DefaultUpdateLimit per DefaultUpdateWindow.

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin keeps the three documents. Build it with New.

func New

func New(opts ...Option) *Plugin

New builds the plugin.

func (*Plugin) Get

func (p *Plugin) Get(ctx context.Context, userID string) (Metadata, error)

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) Init

func (p *Plugin) Init(a *auth.Auth) error

Init captures the core handle every statement goes through.

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) Name

func (p *Plugin) Name() string

Name returns PluginName.

func (*Plugin) Operations

func (p *Plugin) Operations() []auth.Operation

Operations lists the user operations, and the admin pair under WithAdmin.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded; see auth.OptionChecker.

func (*Plugin) PublicOf

func (p *Plugin) PublicOf(ctx context.Context, userID string) (json.RawMessage, error)

PublicOf returns userID's public document alone, for a JWT claim or a server-rendered page.

func (*Plugin) RateLimits

func (p *Plugin) RateLimits() []auth.RateRule

RateLimits counts metadata.update per user (WithUpdateRate).

func (*Plugin) SessionExtras

func (p *Plugin) SessionExtras(ctx context.Context, c *auth.Call) (any, error)

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

func (p *Plugin) Set(ctx context.Context, userID string, kind Kind, doc json.RawMessage) error

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

func (p *Plugin) Tables() []sqlb.Table

Tables declares the documents table, so New can check its alias and an app can rename it.

func (*Plugin) TypedMigrations

func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)

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

func (p *Plugin) UserTables() []auth.UserTable

UserTables tells DeleteUser which row is a user's.

type UnsafeCheck

type UnsafeCheck func(ctx context.Context, userID string, doc json.RawMessage) error

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.

Jump to

Keyboard shortcuts

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