The admin package is a Django-admin-style auto-CRUD panel built entirely
from struct reflection. You register your domain models against a Panel,
and it serves a self-contained web UI — a resource index, paginated and
searchable list views, and create/edit/delete forms — over a plain
http.Handler you mount wherever you like (conventionally /admin/).
It is stdlib-only (net/http, html/template, reflect) and never touches
a database directly. Persistence lives behind the DataSource interface, so
the same UI runs against an in-memory slice (SliceSource, shipped), an
orm-backed adapter you write, or any other store.
Register a model against the shipped in-memory source and mount the handler:
package main
import (
"log"
"net/http"
"github.com/devituz/lagodev/admin"
)
type User struct {
ID uint64 `column:"id" orm:"primary"`
Name string `column:"name"`
Email string `column:"email"`
}
func main() {
// In-memory data source (great for demos/tests).
src := admin.NewSliceSource("id")
src.Seed(
map[string]any{"id": 1, "name": "Ada", "email": "ada@example.com"},
map[string]any{"id": 2, "name": "Linus", "email": "linus@example.com"},
)
p := admin.New(admin.WithTitle("Acme Admin"))
p.Register(User{}, admin.Resource{
Source: src,
Search: []string{"name", "email"},
})
mux := http.NewServeMux()
mux.Handle("/admin/", http.StripPrefix("/admin", p.Handler()))
log.Println("admin on http://localhost:8080/admin/")
log.Fatal(http.ListenAndServe(":8080", mux))
}admin.New(opts...) builds an empty Panel. Register adds a model: pass a
zero/sample value (User{} or &User{}) whose struct tags drive field
discovery, plus a Resource carrying the DataSource and any overrides.
Register panics on misuse (non-struct model, missing Source, duplicate
slug) — these are startup-time programmer errors, matching the framework's
other registries.
Handler() returns the http.Handler. Mount it under the panel's base path
(default /admin) with http.StripPrefix so internal link building lines up.
Relative to the mount point, Handler() serves:
GET / resource index
GET /{resource} paginated, searchable list
GET /{resource}/new create form
POST /{resource}/new create
GET /{resource}/{id} edit form
POST /{resource}/{id} update
POST /{resource}/{id}/delete delete (soft-delete aware)
{resource} is the model's slug (default: snake_case of the type name, e.g.
User -> user).
Resource declares how one model is exposed. Only Source is required;
everything else defaults from reflected struct tags.
p.Register(BlogPost{}, admin.Resource{
Source: postSource, // required: persistence backend
Slug: "posts", // URL segment (default: snake_case type)
Label: "Blog Posts", // UI label (default: humanized type)
Columns: []string{"id", "title", "published"}, // list-table columns, in order
Editable: []string{"title", "body", "published"}, // form inputs
Search: []string{"title", "body"}, // free-text search fields
Filters: []string{"published"}, // exact-match filter inputs
PerPage: 25, // list page size (default 20)
})| Field | Default when zero |
|---|---|
Slug |
inflect.Snake(TypeName) — BlogPost -> blog_post |
Label |
Humanized type name — BlogPost -> Blog Post |
Columns |
Every non-relation field |
Editable |
Every field except the PK and managed timestamps |
Search |
Empty — search box disabled |
Filters |
Empty — filtering disabled |
PerPage |
admin.DefaultPerPage (20) |
Columns come from reflecting the struct, mirroring the ORM's tag conventions (see ORM.md):
| Tag / convention | Effect |
|---|---|
column:"name" |
Storage/data-source key (default: snake_case of field) |
column:"-" |
Skip the field entirely |
orm:"-" |
Skip the field entirely |
orm:"primary" |
Mark as primary key (the row identifier) |
Well-known fields are recognised by name automatically: ID is the primary
key; CreatedAt / UpdatedAt are read-only timestamps (shown but not
editable); a DeletedAt field (time.Time or *time.Time) marks the model
soft-delete aware. Embedded anonymous structs — such as a shared orm.Model
mixin — are flattened, so the standard timestamp/PK fields are picked up.
Each discovered Field carries a Kind that classifies its form input:
text, number, bool, or datetime. Slice or pointer-to-struct fields are
treated as relations and hidden from the list table by default.
The list view (GET /{resource}) reads three query parameters:
GET /admin/user?q=ada&filter_published=true&page=2
q— free-text search, matched case-insensitively as a substring against the fields named inResource.Search. NoSearchfields means the box is hidden.filter_<column>— exact-match filter for each column inResource.Filters. Multiple filters are AND-ed.page— 1-indexed page number; the source clamps out-of-range values.
The panel passes these to the source as ListParams:
type ListParams struct {
Page int // 1-indexed
PerPage int // page size
Search string // free-text term
SearchFields []string // columns Search matches against
Filters map[string]string // exact-match, AND-ed
IncludeDeleted bool // include soft-deleted rows
SoftDeleteColumn string // "" when not soft-delete aware
}Create and update submit the form as POST, coercing each editable field to a
native Go type by its Kind (checkbox -> bool, numeric input -> int64 /
float64, otherwise string) before handing the map[string]any to the
source. Delete is a POST to /{resource}/{id}/delete. When the model is
soft-delete aware, the source decides whether to stamp the DeletedAt column
or physically remove the row.
The panel programs against DataSource. SliceSource is the reference
in-memory implementation; for live persistence you supply your own. Rows are
flat map[string]any keyed by column name (the same keys reflection derives).
type DataSource interface {
List(ctx context.Context, params ListParams) (rows []map[string]any, total int, err error)
Get(ctx context.Context, id string) (map[string]any, error)
Create(ctx context.Context, data map[string]any) (map[string]any, error)
Update(ctx context.Context, id string, data map[string]any) (map[string]any, error)
Delete(ctx context.Context, id string) error
}Get / Update / Delete return admin.ErrNotFound when no row matches the
id; the panel translates that to HTTP 404. Implementations must be safe for
concurrent use.
An orm-backed adapter over orm.Query[T] (see ORM.md) looks like:
type userSource struct{ conn *database.Connection }
func (s userSource) List(ctx context.Context, p admin.ListParams) ([]map[string]any, int, error) {
q := orm.Query[User](s.conn)
for col, val := range p.Filters {
q = q.Where(col, "=", val)
}
if p.Search != "" {
for _, f := range p.SearchFields {
q = q.OrWhere(f, "ilike", "%"+p.Search+"%")
}
}
total, err := q.Count(ctx)
if err != nil {
return nil, 0, err
}
var users []User
err = q.OrderBy("id", "desc").
Limit(p.PerPage).
Offset((p.Page - 1) * p.PerPage).
Get(ctx, &users)
if err != nil {
return nil, 0, err
}
rows := make([]map[string]any, len(users))
for i, u := range users {
rows[i] = u.ToMap()
}
return rows, total, nil
}
func (s userSource) Get(ctx context.Context, id string) (map[string]any, error) {
u, err := orm.Query[User](s.conn).Find(ctx, id)
if errors.Is(err, orm.ErrNotFound) {
return nil, admin.ErrNotFound
}
if err != nil {
return nil, err
}
return u.ToMap(), nil
}
// Create / Update build a *User from data and call orm.Save; Delete calls
// orm.Delete (soft-delete aware when the model embeds orm.Model).Map orm.ErrNotFound to admin.ErrNotFound so the panel renders 404 rather
than 500.
The panel is fail-closed by default: with no Authorizer installed it
denies every request with HTTP 403. A misconfigured mount therefore exposes
nothing — you must explicitly grant access before the panel serves anything.
Make it reachable one of two ways:
WithAuthorizer(fn)— the recommended path. Installs a per-action RBAC gate (see below). This is what production should use.WithInsecureAllowAll()— the explicit opt-out of the gate. It lets every action through with no authorization check. Use it only for local development, tests, and demos, or when the mount is already fully protected by upstream auth middleware. Never put it on a route reachable without an upstream auth layer: it exposes full CRUD over every registered model to anyone who can hit the path.
// Production: gate every action by (request, action, model).
p := admin.New(admin.WithAuthorizer(myRBAC))
// Local/dev demo only — no auth check at all:
p := admin.New(admin.WithInsecureAllowAll())Gate it the rest of the way — usually with both an Authorizer and front-door
middleware.
1. Front the mount with middleware. Because Handler() is a plain
http.Handler, wrap it with any auth middleware before mounting:
func requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isAdminSession(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
mux.Handle("/admin/", requireAdmin(http.StripPrefix("/admin", p.Handler())))2. Per-action RBAC with an Authorizer. WithAuthorizer installs a hook
consulted before every action; returning false yields HTTP 403:
type Authorizer func(r *http.Request, action, model string) boolaction is one of admin.ActionList, ActionView, ActionCreate,
ActionUpdate, ActionDelete. model is the resource slug (empty for the
top-level index).
p := admin.New(
admin.WithTitle("Acme Admin"),
admin.WithAuthorizer(func(r *http.Request, action, model string) bool {
u := currentUser(r)
if u == nil || !u.IsAdmin {
return false
}
// Only owners may delete the users resource.
if model == "user" && action == admin.ActionDelete {
return u.IsOwner
}
return true
}),
)With no Authorizer and no WithInsecureAllowAll(), the panel denies every
request (fail-closed). Install one or the other to make it reachable.
- Title —
admin.WithTitle("Acme Admin")sets the header and page titles. - Mount path —
admin.WithBasePath("/backoffice")changes the prefix used to build internal links. MountHandler()under the matching prefix:http.StripPrefix("/backoffice", p.Handler()). - Columns and inputs —
Resource.ColumnsandResource.Editablepick and order which fields show in the list table and which render as form inputs. - Labels —
Resource.LabelandResource.Slugoverride the display name and URL segment per model.
The rendered HTML is a fixed, self-contained template set (no external assets, no JS framework); customization is through configuration rather than template overrides.
- CSRF — every mutating request (create/update/delete) is
POST-only and guarded by a double-submit token (lago_admin_csrfcookie echoed in thecsrfform field). Forms carry the token automatically. Drive the panel through its own forms; a bare scriptedPOSTmust replay both the cookie and the field. - Auto-escaping — all output goes through
html/template, so row values are escaped on render. - Fail-closed by default — there is no built-in login, and the panel denies
every request until you install
WithAuthorizeror explicitly opt out withWithInsecureAllowAll. Put the panel behind auth middleware and, for least privilege, anAuthorizer. ReserveWithInsecureAllowAllfor local/dev or mounts already protected upstream. Never expose it on a public path unauthenticated. - Serve over TLS — the CSRF cookie is
SameSite=Lax; terminate TLS at your ingress/proxy and restrict the route to trusted networks where possible. - Register at startup — register every resource during setup before serving.
A
Panelis safe for concurrent use after registration but registration itself is not concurrency-safe. - Honour
ctx— yourDataSourceshould respectctxcancellation and enforce sanePerPagebounds so a hostilepage/filter query cannot exhaust the backend.
- ORM.md — query builder used to back a real
DataSource. - GETTING_STARTED.md — project scaffolding and the
webserver the panel mounts alongside.