admin

package
v0.13.0 Latest Latest
Warning

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

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 24 Imported by: 0

Documentation

Overview

Package admin is a model-driven CRUD panel: register a struct, get a working list and edit screen.

Django's admin is still the canonical reason people pick Django, and Go has had nothing maintained -- the largest project in the space had not been pushed to in fourteen months when this was written. This is the useful 90% of it: reflection over the structs an application already has, rendered server-side with the query builder and the standard library.

Three lines

panel := admin.New(admin.Config{DB: db, Driver: "sqlite", Authorizer: myRules})
panel.Register(admin.Resource{Model: User{}, Table: "users"})
mux.Mount("/admin", panel.Handler("/admin"))

That is a list with search, filters, sorting and pagination, and a form with an input per column chosen from the field's type.

It refuses to work until you say who may use it

The zero value of Config has no authorizer, and a panel without one answers every request with 404. There is no permissive default, because the failure mode of one is a CRUD interface to the entire database published to the internet by someone who was going to configure it later.

404 rather than 403, and for an unauthenticated visitor rather than 403 with a login prompt: an admin panel that confirms its own existence has told a scanner exactly where to point the credential stuffing.

What it deliberately is not

Filament is a framework inside a framework -- form builders, widgets, charts, a plugin ecosystem -- and that is both why it has three million installs and why it is somebody's full-time job. This is model-driven CRUD plus a slot for custom pages, and it stops there. The custom-page slot is half the value: the admin is where internal tooling ends up living.

No build step

Server-rendered HTML with inline CSS, and the only JavaScript is a handful of lines for the select-all checkbox. No npm, no bundler, no CDN -- which also means no CSP exception and nothing to keep up to date.

Rendering is html/template rather than the framework's Jet. Jet escapes, but html/template escapes *contextually* -- differently inside an attribute, a URL and a script -- and this package renders arbitrary database content into all three. That is the whole argument; it is also one fewer dependency.

Index

Constants

View Source
const (
	// DefaultPerPage is a page of rows.
	DefaultPerPage = 25

	// MaxPerPage bounds what a URL may ask for. Without it, ?per_page=1000000
	// is a denial of service one query string long.
	MaxPerPage = 200

	// MaxListColumns keeps a wide table readable, and keeps the row's own
	// actions on screen rather than off the right-hand edge.
	MaxListColumns = 6
)
View Source
const DefaultHasManyLimit = 25

DefaultHasManyLimit bounds an inline list, because "show every related row" is how an admin page for a popular record becomes a database incident.

View Source
const MaxUploadBytes = 32 << 20 // 32 MiB

MaxUploadBytes bounds a single file. Without a cap, an upload field is a way to fill a disk from a browser.

Variables

View Source
var (
	ErrUnauthenticated = errors.New("admin: not signed in")
	ErrForbidden       = errors.New("admin: not permitted")
)

The two ways to refuse, and the difference between them is what a visitor already knows.

ErrUnauthenticated becomes 404: nobody has identified themselves, so the answer must not confirm that this path is an admin panel, that a resource by that name exists, or that the record they guessed at is real. An admin panel that announces itself has told a scanner where to point the credential stuffing.

ErrForbidden becomes 403: this is a known account being told no. Hiding the panel from someone already looking at it buys nothing and produces support tickets about pages that "disappeared".

Functions

func DefaultPermissions

func DefaultPermissions() map[Action]auth.Permission

DefaultPermissions is the mapping most applications want: reading needs read, everything that writes needs write.

Types

type Action

type Action string

Action is what a request is trying to do.

const (
	ActionList   Action = "list"
	ActionView   Action = "view"
	ActionCreate Action = "create"
	ActionUpdate Action = "update"
	ActionDelete Action = "delete"
)

type Audit

type Audit struct {

	// Retain bounds how long entries are kept. Zero keeps them forever, which
	// is a decision rather than a default: an audit trail that a cleanup job
	// truncates is not one.
	Retain time.Duration
	// contains filtered or unexported fields
}

Audit records who changed what.

The question an admin panel eventually produces is "who deleted that", and it can only be answered if the answer was written down before it was asked. One row per write, with the changed columns and nothing else -- storing whole records would turn the trail into a second copy of every table, including the columns this package refuses to display.

func NewAudit

func NewAudit(db *sql.DB, driver string) *Audit

NewAudit returns an audit trail on the default table.

driver is the DATABASE_TYPE value, because the DDL below differs between MySQL and SQLite in ways the placeholder dialect does not distinguish -- both use ? and only one of them spells it AUTOINCREMENT.

func (*Audit) For

func (a *Audit) For(ctx context.Context, resource, recordID string, limit int) ([]Entry, error)

For returns the entries about one record, newest first.

func (*Audit) Migrate

func (a *Audit) Migrate(ctx context.Context) error

Migrate creates the table if it does not exist.

func (*Audit) Prune

func (a *Audit) Prune(ctx context.Context) (int64, error)

Prune deletes entries older than Retain. Run it from the scheduler.

func (*Audit) Record

func (a *Audit) Record(ctx context.Context, e Entry) error

Record writes an entry.

func (*Audit) WithTable

func (a *Audit) WithTable(name string) *Audit

WithTable overrides the table name.

type Authorizer

type Authorizer interface {
	Allow(ctx Context, q Query) error
}

Authorizer decides who may do what.

There is no default that allows anything. A panel without an authorizer refuses every request, because the failure mode of the other default is a CRUD interface to the whole database published to the internet.

var AllowAll Authorizer = AuthorizerFunc(func(Context, Query) error { return nil })

AllowAll permits everything.

For local development and for tests. Naming it this loudly is deliberate: it should be obvious in a diff, and it should be obvious in a review that a production configuration containing it is a finding.

var DenyAll Authorizer = AuthorizerFunc(func(Context, Query) error {
	return ErrUnauthenticated
})

DenyAll refuses everything, invisibly. It is the zero-configuration default.

func RoleAuthorizer

func RoleAuthorizer(store auth.OrganizationStore, perms auth.Permissions, who Organization, required map[Action]auth.Permission) Authorizer

RoleAuthorizer permits actions by the account's role in its organization, using the auth package's membership lookup.

The lookup happens on every request rather than being read from the session. A session carrying the role keeps granting it after the person has been removed, until they happen to log out -- which is the difference between revocation and a suggestion.

required maps an action to the permission it needs. Actions missing from the map are refused, so adding an action to this package cannot silently widen what an existing deployment allows.

type AuthorizerFunc

type AuthorizerFunc func(ctx Context, q Query) error

AuthorizerFunc adapts a function.

func (AuthorizerFunc) Allow

func (f AuthorizerFunc) Allow(ctx Context, q Query) error

type BelongsTo

type BelongsTo struct {
	// Table is the table the key points at.
	Table string

	// Key is the column in that table, usually its primary key.
	Key string

	// Label is the column to show a person. An id in a dropdown is a puzzle.
	Label string
}

BelongsTo describes a foreign key.

type BulkAction

type BulkAction struct {
	// Name appears in the URL and must be unique within the resource.
	Name string

	// Label is the button.
	Label string

	// Confirm asks first. Use it for anything that destroys data.
	Confirm bool

	// Run receives the selected primary keys.
	//
	// Authorization has already run: the panel checks ActionUpdate on every
	// selected record before calling this, so a bulk action cannot be a way
	// around per-record permissions.
	Run func(ctx Context, ids []string) error
}

BulkAction is something a person can do to several selected rows at once.

type Choice

type Choice struct {
	Value string
	Label string
}

Choice is one option in a select.

type Config

type Config struct {
	// DB is the database the resources live in.
	DB *sql.DB

	// Driver is the application's DATABASE_TYPE: "sqlite", "mysql",
	// "mariadb", "postgres". It selects both the placeholder syntax and the
	// audit table's schema.
	Driver string

	// Authorizer decides who may do what. Without one nothing is permitted.
	Authorizer Authorizer

	// Title is shown in the header. Defaults to "Admin".
	Title string

	// Actor names the person behind a request, for the audit trail. Optional;
	// without it the trail records the action and not who took it, which is
	// half a trail.
	Actor func(ctx Context) string

	// Audit records who changed what. Nil disables it.
	//
	// Enabled by default when a DB is present: an admin panel is where someone
	// eventually asks "who deleted that", and the answer has to have been
	// recorded before the question.
	Audit *Audit

	// Uploads receives files from file fields. Without it, a file field falls
	// back to a plain text input for a path.
	Uploads Uploader
}

Config configures a panel.

type Content

type Content = template.HTML

Content is HTML a custom page produced. It is inserted without escaping, so a page that renders user input must escape it -- html/template is right there.

type Context

type Context struct {
	context.Context

	// Request is the HTTP request behind this, for a session lookup or a
	// header. Never nil.
	Request *http.Request
}

Context is what an authorizer and a bulk action are given.

type Entry

type Entry struct {
	At       time.Time
	Actor    string
	Action   Action
	Resource string
	RecordID string

	// Changes holds only what differed, as column -> [before, after]. A create
	// records the values it set; a delete records the record as it was.
	Changes map[string]any
}

Entry is one recorded change.

func (Entry) Summary

func (e Entry) Summary() string

changeSummary renders an audit entry's changes for the history list.

type Field

type Field struct {
	// Name is the database column.
	Name string

	// Label is what a person sees. Derived from the column unless overridden.
	Label string

	Kind FieldKind

	// PrimaryKey marks the identifying column. Never editable.
	PrimaryKey bool

	// ReadOnly shows the value but does not accept a new one.
	//
	// Enforced when the form is processed, not only when it is drawn. A
	// disabled input is a suggestion to the browser and nothing at all to
	// anything else.
	ReadOnly bool

	// Hidden keeps the column out of every screen.
	Hidden bool

	// Required rejects an empty value.
	Required bool

	// Searchable includes the column in the search box's LIKE.
	Searchable bool

	// Relation is set for a belongs-to column: the values offered are the
	// related table's rows.
	Relation *BelongsTo

	// Choices turns a column into a fixed dropdown.
	Choices []Choice
}

Field is one column of a resource.

type FieldKind

type FieldKind string

FieldKind is how a column is rendered and parsed.

const (
	KindText     FieldKind = "text"
	KindLongText FieldKind = "longtext"
	KindNumber   FieldKind = "number"
	KindDecimal  FieldKind = "decimal"
	KindBool     FieldKind = "bool"
	KindTime     FieldKind = "time"
	KindEmail    FieldKind = "email"
	KindURL      FieldKind = "url"
	KindSelect   FieldKind = "select"
	KindFile     FieldKind = "file"
)

type FileStore

type FileStore struct {
	// FS is the destination.
	FS filesystems.FS

	// Folder is the prefix within it.
	Folder string

	// MaxBytes overrides MaxUploadBytes.
	MaxBytes int64

	// AllowedExtensions restricts what may be uploaded, lower case and with
	// the dot: {".png", ".jpg"}. Empty allows anything, which is a decision to
	// make deliberately -- an admin panel that accepts .html is a way to host
	// an attacker's page on your origin, and one that accepts .svg is the same
	// thing with a friendlier extension.
	AllowedExtensions []string
}

FileStore stores uploads through the framework's filesystems package, so a panel gets S3 or MinIO by being handed the filesystem the application already configured.

func (FileStore) Upload

func (s FileStore) Upload(ctx Context, field string, file multipart.File, header *multipart.FileHeader) (string, error)

Upload writes the file and returns the path it was stored under.

type HasMany

type HasMany struct {
	// Title is the heading above the list.
	Title string

	// Table holds the related rows.
	Table string

	// ForeignKey is the column in Table pointing at this record.
	ForeignKey string

	// Columns are shown, in order. Empty means the first three the table has.
	Columns []string

	// Limit caps how many are listed. Zero means DefaultHasManyLimit.
	Limit int
}

HasMany describes rows in another table that point back at this one.

type Organization

type Organization func(ctx Context) (orgID, accountID string, err error)

Organization is how a request's account and organization are found.

The panel cannot know where an application keeps them -- session, JWT, mTLS certificate -- so it asks. Return ("", "", ErrForbidden) for an unauthenticated request.

type Page

type Page struct {
	// Path is the URL segment, e.g. "reports".
	Path string

	// Title is the nav entry and heading.
	Title string

	// Body renders the page's content, which the panel wraps in its chrome.
	//
	// Returning HTML rather than writing to the ResponseWriter is what makes
	// the page look like the rest of the panel without every page having to
	// reproduce the layout.
	Body func(ctx Context) (Content, error)

	// Handler takes the whole request instead, for a page that streams, or
	// redirects, or is not HTML. Set one or the other; Handler wins.
	Handler http.Handler

	// Post handles a form submitted from this page and returns where to send
	// the browser next. An empty redirect returns to the page itself.
	//
	// A page without one accepts no writes at all: an internal tool that only
	// reads should not have a POST endpoint just because pages can have them.
	Post func(ctx Context) (redirect string, err error)

	// Action is the permission checked before the page is shown. Defaults to
	// ActionList.
	Action Action

	// PostAction is the permission checked before Post runs. Defaults to
	// ActionUpdate, so a page that reads with one role and writes with another
	// gets that without configuration.
	PostAction Action
}

Page is a custom screen: the half of an admin panel that is not CRUD.

type Panel

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

Panel is a mounted admin.

func New

func New(cfg Config) *Panel

New returns a panel.

func (*Panel) AddPage

func (p *Panel) AddPage(pages ...Page) *Panel

AddPage registers a custom screen.

func (*Panel) Audit

func (p *Panel) Audit() *Audit

Audit returns the trail, so an application can migrate it, prune it, or read it from a page of its own.

func (*Panel) Err

func (p *Panel) Err() error

Err reports a registration problem, if there was one.

func (*Panel) Handler

func (p *Panel) Handler(mount string) http.Handler

Handler returns the panel's routes, to be mounted at mount.

mount is needed because every link the panel writes is absolute: a panel that guessed its own prefix from the request would break the moment it was mounted behind a path-rewriting proxy, and guessing wrong means every link is broken rather than one.

func (*Panel) Register

func (p *Panel) Register(resources ...Resource) *Panel

Register adds a resource. The first invalid one is reported by Handler.

func (*Panel) Resources

func (p *Panel) Resources() []*Resource

Resources returns the registered resources, for a nav or a test.

type Query

type Query struct {
	Action   Action
	Resource string
	Field    string
	Record   map[string]any
}

Query is one authorization question.

Record is nil for a list or a create -- there is no row yet. Field is set only when the question is about one column, which is what makes per-field permissions possible without a second interface.

type Resource

type Resource struct {
	// Model is any value of the struct type. Its fields are reflected over;
	// it is never stored or mutated.
	Model any

	// Table is the database table.
	Table string

	// Name is the URL segment and heading. Derived from Table when empty.
	Name string

	// Singular and Plural override the headings.
	Singular string
	Plural   string

	// PrimaryKey defaults to "id".
	PrimaryKey string

	// ListColumns limits the list view. Empty shows every visible field, up to
	// MaxListColumns -- a table with forty columns is not a useful screen.
	ListColumns []string

	// DefaultSort is the column the list is ordered by. Defaults to the
	// primary key, descending.
	DefaultSort string
	DefaultDesc bool

	// PerPage defaults to DefaultPerPage.
	PerPage int

	// HasMany renders related rows on the edit screen.
	HasMany []HasMany

	// BulkActions appear on the list when rows are selected.
	BulkActions []BulkAction

	// FieldOverrides adjusts what reflection worked out. The key is the column.
	FieldOverrides map[string]Field

	// ReadOnly forbids create, update and delete for everyone, regardless of
	// what the authorizer says. For tables that are reports.
	ReadOnly bool
	// contains filtered or unexported fields
}

Resource is a table exposed in the panel.

The minimum is a model and a table:

panel.Register(admin.Resource{Model: User{}, Table: "users"})

Everything else -- columns, labels, input types, which fields are searchable -- is read off the struct.

func (*Resource) EditableFields

func (r *Resource) EditableFields() []Field

EditableFields are the ones a form may write.

func (*Resource) Field

func (r *Resource) Field(name string) (Field, bool)

Field returns one column by name.

func (*Resource) Fields

func (r *Resource) Fields() []Field

Fields returns the resource's columns, working them out on first use.

func (*Resource) ListFields

func (r *Resource) ListFields() []Field

ListFields are the columns of the list view.

func (*Resource) SearchFields

func (r *Resource) SearchFields() []Field

SearchFields are the text columns the search box matches against.

func (*Resource) VisibleFields

func (r *Resource) VisibleFields() []Field

VisibleFields are the ones a screen may show.

type Uploader

type Uploader interface {
	Upload(ctx Context, field string, file multipart.File, header *multipart.FileHeader) (string, error)
}

Uploader stores a file from a file field and returns the value to write into the column.

What that value means is the application's business -- a key, a path, a URL. This package writes back whatever it is given.

type UploaderFunc

type UploaderFunc func(ctx Context, field string, file multipart.File, header *multipart.FileHeader) (string, error)

UploaderFunc adapts a function.

func (UploaderFunc) Upload

func (f UploaderFunc) Upload(ctx Context, field string, file multipart.File, header *multipart.FileHeader) (string, error)

Jump to

Keyboard shortcuts

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