admin

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: 17 Imported by: 0

README

github.com/ubgo/auth/plugin/admin

Gives an app's operators the user operations every product needs: find a user, see their identities and session count, create a user, set a password or an address, ban and unban, end their sessions, grant or remove admin rights, act as a user for support and come back to their own session, and read the audit log.

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/admin-plan:

ad := admin.New()
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),
)
// then: a.Migrate(ctx), and mount authhttp.Handler(a, authhttp.Prefix("/auth"))

The first admin is granted from Go, because nobody can grant it over HTTP before an admin exists. The example does it from a subcommand, for a verified address only:

idn, err := a.FindIdentity(ctx, email.KindEmail, address, true) // verified only
err = ad.Grant(ctx, idn.UserID)

The plugin depends on no other plugin and has no Validate refusals. At Init it looks for a registered plugin that can set a password (PasswordSetter, which *password.Plugin is), one that can give a user an address (EmailSetter, which *email.Plugin is) and an audit log (EventRecorder to record a Go-side ban, EventQuerier to serve the audit route; *audit.Plugin is both, and any other log with the same methods is used instead); create-user, set-password, set-email and audit answer with a 409 code when the plugin they need is absent, and everything else works without them. An app's own admin-only routes read the same column:

var cols admin.AdminColumns
if err := a.LoadUserColumns(r.Context(), authhttp.MustUser(r).ID, admin.Bindings(&cols)...); err != nil {
	return err
}
if cols.AdminGrantedAt == nil {
	return admin.ErrForbidden
}

Options

Who is an admin is data (admin_granted_at); the options add fixed admins and defaults.

Option What it does Default
admin.AdminIDs(ids...) The listed users are admins without a grant row, and are protected like admins (no ban, impersonation or removal) none
admin.DefaultBanReason(s) Stored when a ban names no reason empty
admin.DefaultBanDuration(d) A ban whose request names no minutes lifts itself after d until unban
admin.ImpersonationTTL(d) Length of an impersonation whose request names no minutes; the core cap of one hour still applies the cap
admin.SearchRate(limit, window) The per-IP limit on search-users; auth.New refuses a limit below one, so the search can be loosened but never left unlimited 30 per minute
admin.AllowImpersonatingAdmins() An admin may impersonate another admin; everything else about impersonation stays (see the risk below) refused, admin.admin_target
admin.EditableColumns(admin.Editable(col, check), …) The app columns (auth.WithUserColumns) an admin may set through update-user's data, typed by the column's Go type and checked by check (nil for none); written with auth.SetUserColumn under auth.AppName none: data takes name and image only
admin.BannedMessage(func(ctx, admin.Ban) string) The text a banned user reads at sign-in, from the ban's reason and dates; an empty result falls back to the default "This account is banned."

Routes and Go API

Routes are under the mount prefix (/auth below). A Query is served as GET with its input in the query string; a Mutation as POST with a JSON body. Every operation first refuses a caller whose roles lack its permission (DefaultStatements; see Roles below) with ErrForbidden. FreshSignIn means a session created within auth.DefaultFreshWindow (10 minutes, auth.WithFreshWindow to change); the core refuses every FreshSignIn operation on an impersonation session.

Route Go method Requirement Rate limit
POST /auth/admin/set-role {userId, role} (a string or an array) SetRole(ctx, SetRoleRequest) FreshSignIn, user:set-role none
POST /auth/admin/has-permission {userId, permissions, connector} HasPermission(ctx, PermissionRequest) SignedIn; another user's needs user:get none
GET /auth/admin/users?query=&page=&limit=&offset=&sort=&sortBy=&sortDirection=&admin=&banned=&filterField=&filterOperator=&filterValue= Users(ctx, UsersRequest) SignedIn none
GET /auth/admin/search-users?value=&field=&operator= plus every users parameter but query SearchUsers(ctx, SearchUsersRequest) SignedIn SearchRate, per IP
GET /auth/admin/user?userId= User(ctx, userID) SignedIn none
POST /auth/admin/create-user {name, image, email, verified, password} CreateUser(ctx, CreateUserRequest) FreshSignIn none
POST /auth/admin/update-user {userId, name, image, data} UpdateUser(ctx, UpdateUserRequest) FreshSignIn none
POST /auth/admin/set-password {userId, password} SetUserPassword(ctx, userID, password) FreshSignIn none
POST /auth/admin/set-email {userId, email, verified} SetUserEmail(ctx, SetEmailRequest) FreshSignIn none
POST /auth/admin/ban {userId, reason, minutes} Ban(ctx, userID, reason) FreshSignIn none
POST /auth/admin/unban {userId} Unban(ctx, userID) FreshSignIn none
GET /auth/admin/sessions?userId= Sessions(ctx, userID) SignedIn none
GET /auth/admin/all-sessions?userId=&page=&limit=&offset= AllSessions(ctx, AllSessionsRequest) SignedIn, session:list none
POST /auth/admin/revoke-session {userId, sessionId} RevokeSession(ctx, userID, sessionID) FreshSignIn none
POST /auth/admin/revoke-sessions {userId} RevokeSessions(ctx, userID) FreshSignIn none
POST /auth/admin/remove-user {userId} RemoveUser(ctx, userID) FreshSignIn none
POST /auth/admin/set-admin {userId, admin} SetAdmin(ctx, userID, admin) FreshSignIn none
POST /auth/admin/impersonate {userId, minutes} Impersonate(ctx, ImpersonateRequest) FreshSignIn none
POST /auth/admin/stop-impersonating → {ok, restored} StopImpersonating(ctx) (StopResult, error) SignedIn, on an impersonation session none
GET /auth/admin/audit?userId=&op=&page=&limit= Audit(ctx, audit.Query) SignedIn none
GET /auth/admin/two-factor?userId= → {enrolled, enabled, method} TwoFactor(ctx, userID) SignedIn, two-factor:get none
POST /auth/admin/disable-two-factor {userId} DisableTwoFactor(ctx, userID) FreshSignIn, two-factor:disable none
POST /auth/admin/enable-two-factor {userId} EnableTwoFactor(ctx, userID) FreshSignIn, two-factor:enable none
POST /auth/admin/reset-two-factor {userId} ResetTwoFactor(ctx, userID) FreshSignIn, two-factor:reset none
(Go only) Grant(ctx, userID) none: call it from trusted code none
(command line) myapp auth admin grant --email x, myapp auth admin create --email x --name y --password-stdin the operator's shell; authcli finds this plugin's Grant by interface none
(Go only) ResetTwoFactorFor(ctx, userID) resets a user's second factor with no signed-in admin (an operator's recovery command), an admin's included; recorded in the audit log with no actor when one is registered (EventRecorder), in the same transaction none: call it from trusted code none
(Go only) BanFor(ctx, userID, reason, until) bans with no signed-in admin (an import, a job); a zero until is permanent; the user's sessions end; recorded in the audit log with no actor when one is registered (EventRecorder) none: call it from trusted code none

Operation ids are the Endpoint* constants (admin.users and so on).

Behaviour worth knowing:

  • users: query is matched exactly, as a normalized identifier (address, number, handle) or a user id, never as a fragment. Pages start at 1; limit defaults to auth.WithDefaultPageSize when the app set one, else 50 (DefaultPageSize), and is clamped to 200 (MaxPageSize); offset, when set, replaces page. sort is one of created_desc (default), created_asc, name_asc, name_desc (UserSortValues); anything else is admin.bad_sort. admin and banned filter with true or false (FlagValues); anything else is admin.bad_filter. sortBy (createdAt, updatedAt, name, id; SortFieldValues) with sortDirection (asc, the default, or desc) is better-auth's sort and replaces sort; naming both is admin.bad_sort. filterField, filterOperator and filterValue are better-auth's one field filter: the field is one of FilterFieldValues (email by default), the operator one of FilterOperatorsOf(field) (eq by default), the value comma-joined for in and not_in (at most MaxFilterValues), true/false for a flag, RFC 3339 for a time; an absent value filters nothing, an unknown field or operator is refused regardless. Addresses compare folded and their negations include users with no address; names, ids and ban reasons keep case; role means a role the user holds by RolesOf's rules (stored, the flag's admin role, AdminIDs, or the default for a user with none). The result carries total, and page, limit and offset as actually used. Every row carries email (the primary email address, else the earliest email identity, verified or not, else empty), roles (every role the user holds by RolesOf's rules), both loaded for the whole page in one query rather than per row, and lastActiveAt, the latest last_seen_at among the user's sessions (every row auth_session still holds when sessions are in SQL, the live ones otherwise, only the caller's in stateless mode), or null.
  • search-users finds users by a fragment: field is email (default) or name (SearchFieldValues), operator is contains (default), starts_with or ends_with (SearchOperatorValues), and value is 1 to 320 characters after trimming (admin.search_value otherwise). Every character of value is text: %, _ and the escape character ! match themselves, through sqlb's escaped LIKE ... ESCAPE '!'. An email search reads the user's email identities, verified or not, and ignores case: addresses are stored folded and the value is folded the same way in Go (trimmed, lowercased; a custom email.WithNormalizer is not applied to a fragment), then compared exactly. A name search matches the display name as stored, case included, on every engine: names have no normalized form and the library never folds case in SQL, so ada does not find Ada; on SQLite, whose LIKE ignores ASCII case, it is GLOB with the value's *, ? and [ escaped. It pages, sorts and filters exactly like users and returns the same result. Without the email plugin an email search is 409 admin.no_email_plugin. It has its own rate rule, 30 searches a minute per IP by default (SearchRate), because a fragment search is the one admin read that can walk the user table.
  • ban stores the reason cut to 300 characters (maxReasonLength, so it fits the column on every engine), sets admin_banned_at and, when minutes or DefaultBanDuration says so, admin_ban_expires_at; then it deletes every session of the user. The ban is also a session check, evaluated per lookup, so a session made later does not work either, on any instance, and a ban with an expiry stops blocking the moment it passes. Deleting the sessions evicts the user's server-side session-cache entries too (auth.EvictCachedSessions); a client-held cache copy expires within the cache TTL.
  • unban clears the ban, its expiry and its reason. revoke-sessions ends sessions without banning; revoke-session ends one, named by user and session id. sessions lists another user's sessions as auth.SessionView rows with current false. all-sessions pages through every live session of every user (FleetSession rows with the user id and expiry), most recently used first; it reads auth_session, so with sessions elsewhere (another auth.WithSessionStore, a mirror, stateless) it is 409 admin.sessions_not_listable, never an empty page.
  • update-user changes the display name or image through the core, with the same limits as the user's own profile update (auth.ErrProfileInvalid); its data object (better-auth's) takes name, image and the EditableColumns columns, decodes and checks every key before the first write, and refuses any other key (password, email, role included, which have their own operations). The edit is one transaction. remove-user hard-deletes a user and everything that references them; an admin must lose the rights first.
  • set-admin with the state the user already has is a no-op that succeeds.
  • create-user creates the user through the core, gives it the address through the email plugin (verified: true makes it verified and primary at once; otherwise the usual verify link goes out) and, when password is set, the password through the password plugin, so every rule those plugins enforce (address uniqueness, one address per user, password length, policies, the breached-password check) applies to an admin-created user; it is one transaction, so a refused password leaves no user. email is required (admin.email_required); password is optional, and without one the user sets theirs through the forgot flow.
  • set-password sets a user's password with the password plugin's rules; every session of the user ends and open reset links are spent, as password.SetPassword promises. set-email gives a user an address under the email plugin's rules (replacing the address without email.Multiple(), adding one with it). Neither works on an admin (admin.admin_target): a stolen admin session must not be able to take over the other admins' accounts, and a verified address recovers an account.
  • A banned user who proves their credential is refused at sign-in with 403 admin.banned and the configured message, on every sign-in method: the refusal is a data hook on the session write, so it covers password, code, link, passkey and OAuth sign-ins and a pending second-factor session alike. It fires only after the credential was proven, so a stranger without the password gets the ordinary wrong-credential answer and learns nothing about the ban.
  • audit pages through the registered audit log (EventQuerier) for admins; see plugin/audit for the query and the event shape. Without one it is 409 admin.no_audit_plugin; with the audit plugin sending events to a sink that cannot be read it is 501 audit.no_query_backend.
  • impersonate returns the user and expiresAt, and issues the new session as an effect (a cookie, or Set-Auth-Token in bearer mode), replacing the admin's own in the session cookie. The admin's own session survives for the way back: its token is rotated (auth.RotateSessionToken, so the value in the overwritten cookie dies) and the new one is bound to the client in the flow cookie __Host-auth_flow (ImpersonateResult.AdminToken for Go callers and bearer clients, which keep it themselves). stop-impersonating ends the impersonation and, when the client still presents that flow value and the session still belongs to the impersonating admin, re-issues it (restored: true); otherwise the client is signed out as before (restored: false). The admin session keeps its own creation time, so coming back never makes it fresh, and a stolen impersonation cookie alone holds a user session, never the admin's. minutes of 0, negative, or above the cap become auth.MaxImpersonationTTL (1 hour). The session row records the admin (Session.ImpersonatedBy), the user sees it in their session list, and every FreshSignIn operation refuses it with auth.impersonation_refused. Start and stop are logged at WARN with both user ids. An admin target is refused (admin.admin_target) unless AllowImpersonatingAdmins(); with it, impersonating yourself is 409 admin.self_target.
  • The two-factor routes manage another user's second factor through whichever registered plugin has the four methods of TwoFactorManager (FactorOf, DisableFor, EnableFor, Reset; plugin/twofactor has them), found at Init by method set, so admin imports no second-factor package; without one they answer 409 admin.no_two_factor_plugin, after the permission check. two-factor reports enrolled (a confirmed factor, on or paused), enabled (sign-in asks for it) and method (totp or delivered). disable-two-factor pauses the factor: sign-in stops asking, the factor and its recovery codes stay, and the user sees two-factor as off and may enrol a new one, which replaces it. enable-two-factor switches a paused factor back on and ends every session of the user, because sessions made during the pause never passed the factor; on a factor that is already on it changes nothing. reset-two-factor removes the factor, its recovery codes and its trusted devices so the user enrols again (twofactor.Reset). Pause and reset leave the user's sessions alone, as twofactor.Reset does: they were made with the factor, and revoke-sessions is the act for a suspected takeover. Pause and reset refuse an admin target (admin.admin_target), for the reason set-password does; enable does not, because raising an admin's protection helps nobody who stole a session; ResetTwoFactorFor from Go accepts an admin, as the way back for one who lost their phone. A user with no confirmed factor is the factor plugin's 409 twofactor.not_enrolled on pause, enable and reset. Every route is recorded by the audit plugin's operation hook like any other mutation.
  • Every admin operation refuses an impersonation session of an admin with 403 auth.impersonation_refused (a non-admin still gets admin.forbidden), so acting as another admin grants none of their admin operations and never chains into a second impersonation.
  • The risk of AllowImpersonatingAdmins(). With it, one stolen admin session (fresh within the window) can act as every other admin: read their account as they see it, use the app as them, and do whatever the app lets them do that is not a FreshSignIn operation. A compromise of the least careful admin becomes a compromise of all of them. Turn it on only where every admin is equally trusted and support work really needs to see another admin's account; the audit log and the WARN lines record every such impersonation with both ids.

Roles

Every permission decision goes through one admin.Authorizer: the first check of every operation (before the target is looked up, so a refusal never reveals whether a user exists), the impersonate-admins check, has-permission and CheckRolePermission. The plugin never decides a permission itself, so you can answer with roles, attributes, a relationship store (OpenFGA, SpiceDB), Casbin, OPA, Cedar or rules of your own.

Authorizer · plugin/admin/authorizer.go:86-88

type Authorizer interface {
	Authorize(ctx context.Context, req AccessRequest) (bool, error)
}

AccessRequest carries UserID (whose permission), Roles (that user's roles as the plugin resolved them), Permissions and Connector (better-auth's permission object), TargetUserID (the user the operation acts on, as the request names it), Operation and Call (client IP, session, decoded input; nil outside an operation). It is a struct so it can grow without breaking an implementation. Any error refuses: the plugin never reads the boolean of a call that failed, so a policy engine that is down fails closed. admin.AuthorizerFunc adapts a function.

The default is two built-in roles: admin may do everything in DefaultStatements except impersonate-admins, user nothing (DefaultRoles()). DefaultStatements is better-auth's vocabulary (user: create, list, set-role, ban, impersonate, impersonate-admins, delete, set-password, set-email, get, update; session: list, revoke, delete) plus two-factor: get, disable, enable, reset (ResourceTwoFactor), its own resource so a support role can be granted it without ban or set-password. A user's roles are auth_user.admin_role, comma-joined; the staff flag (admin_granted_at, AdminIDs) counts as the first of AdminRoles, and a user with neither holds DefaultRole, so an app that never names a role behaves as before.

Fine-grained roles built from a statement vocabulary (better-auth's admin ac and roles) come from the accessadmin adapter in the separate plugin/access module: admin.WithAuthorizer(accessadmin.MustNew(accessadmin.DefaultAccessControl, map[string]access.Role{"support": support})). Any model of your own plugs in the same way; Bring your own authorization shows Casbin- and OpenFGA-style authorizers.

Option What it does Default
admin.WithAuthorizer(a) Decides every admin permission. Implement admin.RoleCatalog (DefinesRole(name) bool) too when set-role should accept roles other than admin and user the built-in roles
admin.AdminRoles(names...) Roles that make an admin: protected from ban, impersonation and removal, counted for the last-admin rule, kept in step with the flag admin
admin.DefaultRole(name) The role of a user with none user

set-role and set-admin keep the flag and the roles in step; Grant still sets the flag only. ErrBadRole (admin.bad_role, 400) is a role the authorizer does not define (RoleCatalog, or admin and user without one), or none. auth.New refuses a nil authorizer (auth.ErrNilOption naming admin.WithAuthorizer), an undefined default or admin role, a role name with a comma and no admin roles. admin.Grants(granted, want, connector) is the role check the built-in roles use, exported for an authorizer that keeps a role table of its own.

Tables and columns

The plugin owns no table. It adds five columns to auth_user:

Column Type Meaning
admin_granted_at timestamp, nullable Set when the user is an admin
admin_banned_at timestamp, nullable Set while the user is banned
admin_ban_reason text, default '' The ban reason (VARCHAR(320) on MySQL)
admin_ban_expires_at timestamp, nullable When a temporary ban lifts itself; null for a ban that lasts until unban
admin_role text, default '' The user's roles, comma-joined (better-auth's user.role); empty means the flag and DefaultRole decide

Typed columns: admin.GrantedAt, admin.BannedAt, admin.BanReason, admin.BanExpiresAt, admin.UserRole; admin.AdminColumns + admin.Bindings for a composite user or LoadUserColumns. These plugin columns are not in the stability contract (stability_test.go covers only the core's own columns).

Errors

Error variable Code HTTP When
ErrForbidden admin.forbidden 403 The caller is not an admin (checked before the target is looked up)
ErrAdminTarget admin.admin_target 409 Banning (over HTTP or BanFor), impersonating, removing, or pausing or resetting the second factor of an admin (granted or listed)
ErrBannedTarget admin.banned_target 409 Impersonating a banned user
ErrSelfTarget admin.self_target 409 Impersonating yourself with AllowImpersonatingAdmins() on (without it, admin.admin_target answers first)
ErrSearchValue admin.search_value 400 search-users with an empty value or one over 320 characters
ErrBadSearchField admin.bad_search_field 400 search-users with a field outside SearchFieldValues
ErrBadSearchOperator admin.bad_search_operator 400 search-users with an operator outside SearchOperatorValues
auth.ErrImpersonationRefused auth.impersonation_refused 403 An admin operation called from an impersonation session of an admin
auth.ErrRateLimited auth.rate_limited 429 More search-users calls from one IP than SearchRate allows
ErrLastAdmin admin.last_admin 409 Removing the last admin's rights
ErrNotImpersonating admin.not_impersonating 409 stop-impersonating on an ordinary session
ErrBadSort admin.bad_sort 400 users or search-users with a sort outside UserSortValues
ErrBadFilterField admin.bad_filter_field 400 filterField outside FilterFieldValues
ErrBadFilterOperator admin.bad_filter_operator 400 filterOperator the field does not take (FilterOperatorsOf)
ErrBadFilterValue admin.bad_filter_value 400 filterValue that is empty, over 320 characters, not a flag or an RFC 3339 time, or more than MaxFilterValues values
ErrSessionsNotListable admin.sessions_not_listable 409 all-sessions while sessions are not in the auth_session table
ErrNoUserData admin.no_user_data 400 update-user with an empty data
ErrFieldNotEditable admin.field_not_editable 400 update-user data key that is not name, image or an EditableColumns column
ErrBadUserData admin.bad_user_data 400 update-user data value of the wrong type
ErrBadFilter admin.bad_filter 400 users or search-users with an admin or banned filter that is not true, false or absent
ErrBanned admin.banned 403 A banned user proved their credential at sign-in; the message is BannedMessage's
ErrNoPasswordPlugin admin.no_password_plugin 409 set-password, or create-user with a password, with no plugin that can store one
ErrNoEmailPlugin admin.no_email_plugin 409 set-email, create-user or an email search with no plugin that can give a user an address
ErrEmailRequired admin.email_required 400 create-user without an address
ErrNoAuditPlugin admin.no_audit_plugin 409 audit with no audit log with a read side (EventQuerier) registered
ErrNoTwoFactorPlugin admin.no_two_factor_plugin 409 A two-factor route, or ResetTwoFactorFor, with no plugin that manages second factors (TwoFactorManager) registered
the factor plugin's twofactor.not_enrolled 409 disable-two-factor, enable-two-factor or reset-two-factor for a user with no confirmed factor
auth.ErrUserNotFound auth.user_not_found 404 The target user does not exist (also from Grant and BanFor)
auth.ErrProfileInvalid auth.profile_invalid 400 update-user or create-user with a name over the limit or a non-https image
auth.ErrIdentityTaken auth.identity_taken 409 create-user or set-email with an address another user holds
the password plugin's password.too_short, password.weak, password.breached and friends 400 create-user or set-password with a password its rules refuse

Security notes

Property Pinned by
A non-admin is refused by every operation, and an unknown target is an error, not a silent success TestOrgCov_AdminRefusesNonAdminsAndUnknownUsers
A ban ends the user's sessions and refuses new ones; unban restores sign-in TestAdmin_BanFindAndLastAdmin
users matches query exactly, never a fragment; fragments go through search-users, which has its own rate rule TestAdmin_BanFindAndLastAdmin, TestAdmin_SearchUsersHasItsOwnRateRule
A fragment search treats %, _, !, *, ? and [ as text, ignores case for addresses and keeps it for names, identically on SQLite, Postgres and MySQL TestAdmin_SearchUsersMatchesFragmentsAsText
search-users refuses a non-admin before reading the input, and refuses an empty or over-long value, an unknown field, operator, sort or filter TestAdmin_SearchUsersRefusals
Every database failure in a search, by email or name, reaches the caller TestAdmin_SearchUsersEveryDatabaseFailureSurfaces
Impersonating an admin is refused by default; with AllowImpersonatingAdmins() it still needs a fresh sign-in, is capped, audited and stoppable, refuses self, and the impersonation session gets no admin operation TestAdmin_ImpersonatingAdminsIsOptIn
An admin cannot be banned, and the last admin cannot be removed TestAdmin_BanFindAndLastAdmin
Impersonation is capped at one hour, visible to the user, refused for FreshSignIn operations, refused for admin and banned targets, and stoppable TestAdmin_ImpersonationIsVisibleLimitedAndStoppable, TestOrgCov_AdminPagingNoOpsRevokeAndBannedTarget
Stopping an impersonation restores the admin's own session, not fresh, only with the flow value and only for the impersonating admin; the admin's old token is dead; without the flow value the client is signed out TestAdmin_StopImpersonatingRestoresTheAdmin
Every database failure reaches the caller; a failed identity lookup in search is an error, not an empty page TestOrgCov_AdminEveryDatabaseFailureSurfaces
The ban check uses only auth_user, so auth.New accepts it as a session predicate TestNew_BanCheckPassesTheSessionCheckGuard
A banned user is refused at sign-in with the configured message on the password and code methods alike, and only after proving the credential: a wrong password gets the ordinary answer TestAdmin_BannedUserIsRefusedAtSignInOnEveryMethod, TestAdmin_BannedMessageDefaultsAndTemporaryBanLifts
An admin-created user signs in at once; a refused password leaves no user; a taken address stays taken; set-password ends the user's sessions; an admin's password and address cannot be set TestAdmin_CreateUserSetPasswordSetEmail
Every filter field and operator answers the same on SQLite, Postgres and MySQL, plain and with prefixed tables; addresses fold, names keep case, a role is a whole element (support is not support_x), and AdminIDs count as admins TestP3Admin_FilterEveryField, TestP3Admin_FilterTimeAndTextOperators, TestP3Admin_RoleFilterFollowsRolesOf, TestP3Admin_RoleFilterCountsAdminIDs
sortBy sorts by each field both ways; mixing it with sort is refused TestP3Admin_SortByEveryField
update-user data sets declared app columns only, typed and checked, refuses password, email, role and unknown keys, and a failed write rolls the whole edit back TestP3Admin_UpdateUserData
all-sessions is admin-only, pages and narrows, drops ended sessions, surfaces every database failure, and is refused (not empty) when sessions are not in SQL; last activity follows the sessions in every store mode TestP3Admin_AllSessionsAndLastActive, TestP3Admin_AllSessionsEveryDatabaseFailureSurfaces, TestP3Admin_SessionsInMemory, TestP3Admin_LastActiveOutsideSQL, TestP3Admin_HTTP
Without the password or email plugin the operations answer with a code TestAdmin_WithoutPasswordOrEmailPluginTheOperationsSaySo
Every database failure in the round-2 operations and in a banned user's sign-in reaches the caller TestAdmin_Round2EveryDatabaseFailureSurfaces

| Every operation checks its permission; a role without it is refused before the target is read; set-role keeps the flag in step and never removes the last admin; impersonating an admin needs impersonate-admins or the opt-in | TestR30_AdminRoles, TestR30_AdminRolesValidate | | The two-factor routes refuse a caller without the permission before reading the target (an unknown id reads the same as a real one); pause keeps the factor, codes and sessions; enable ends the sessions and the kept codes work again; reset removes everything; an admin target cannot be paused or reset over HTTP but can from Go; every route and the Go reset are audited | TestBatch1_AdminTwoFactor | | The routes ask the Authorizer for two-factor permissions, so a role with two-factor:get alone reads and cannot reset; the built-in admin role holds all four | TestBatch1_AdminTwoFactorAuthorizer | | Without a second-factor plugin the routes answer admin.no_two_factor_plugin to an admin only; over HTTP an anonymous caller gets 401 and a non-admin 403 on all four | TestBatch1_AdminTwoFactorWithoutThePlugin, TestBatch1_AdminTwoFactorOverHTTP | | Every database failure in the two-factor routes and ResetTwoFactorFor reaches the caller | TestBatch1_AdminTwoFactorFaults |

Limits: Only search-users has a plugin rate limit; the other operations are only reachable by admins. The audit log is the audit plugin's.

Example

Documentation

Overview

Package admin gives an app's operators the user operations every product needs: find a user, see their identities, ban and unban, end their sessions, and grant or remove admin rights.

An admin is a user whose admin_granted_at column is set. The first admin is granted from Go (Grant), for example from a one-off command, because no one can grant it over HTTP before an admin exists. A ban is a session check: the banned user's sessions stop working on their next request, on every instance. A ban ends the user's sessions, which evicts their entries from a server-side SessionCache (auth.EvictCachedSessions); a client-held cache copy keeps working until it expires, within auth.MaxSessionCacheTTL.

Example (PolicyAuthorizer)

Example_policyAuthorizer plugs a Casbin-style policy into the admin plugin: a support role that may read users but not ban them.

package main

import (
	"context"
	"fmt"
	"slices"

	"github.com/ubgo/auth/plugin/admin"
)

// policyAuthorizer decides from Casbin-style policy lines (subject,
// resource, action), the shape a casbin.Enforcer's Enforce(sub, obj, act)
// answers; with Casbin itself, Authorize calls the enforcer instead of
// scanning the lines. Every action of every resource asked for must be
// granted to one of the user's roles; Or asks for one resource.
type policyAuthorizer struct{ lines [][3]string }

func (p policyAuthorizer) enforce(sub, obj, act string) bool {
	return slices.Contains(p.lines, [3]string{sub, obj, act})
}

// Authorize is the whole adapter: the plugin passes the user's roles and
// the permissions asked for.
//
//ds:def id=policyauthorizer-authorize-cad7rbjj owner=@khanakia stability=stable
func (p policyAuthorizer) Authorize(_ context.Context, req admin.AccessRequest) (bool, error) {
	if len(req.Permissions) == 0 {
		return false, nil // asking for nothing proves nothing
	}
	anyRes := false
	for res, acts := range req.Permissions {
		ok := len(acts) > 0 && slices.ContainsFunc(req.Roles, func(role string) bool {
			return !slices.ContainsFunc(acts, func(act string) bool { return !p.enforce(role, res, act) })
		})
		if !ok && req.Connector != admin.Or {
			return false, nil
		}
		anyRes = anyRes || ok
	}
	return anyRes, nil
}

// DefinesRole lets set-role store the roles the policy names.
func (p policyAuthorizer) DefinesRole(name string) bool {
	return slices.ContainsFunc(p.lines, func(l [3]string) bool { return l[0] == name }) || name == admin.RoleUser
}

// Example_policyAuthorizer plugs a Casbin-style policy into the admin plugin:
// a support role that may read users but not ban them.
//
//ds:def id=example-policyauthorizer-27fwf8au owner=@khanakia stability=stable
func main() {
	authz := policyAuthorizer{lines: [][3]string{
		{"admin", admin.ResourceUser, admin.ActionBan},
		{"admin", admin.ResourceUser, admin.ActionGet},
		{"support", admin.ResourceUser, admin.ActionGet},
	}}
	p := admin.New(admin.WithAuthorizer(authz))

	ctx := context.Background()
	fmt.Println(p.CheckRolePermission(ctx, "support", admin.Permissions{admin.ResourceUser: {admin.ActionGet}}))
	fmt.Println(p.CheckRolePermission(ctx, "support", admin.Permissions{admin.ResourceUser: {admin.ActionBan}}))
	fmt.Println(p.CheckRolePermission(ctx, "support,admin", admin.Permissions{admin.ResourceUser: {admin.ActionBan}}))
}
Output:
true
false
true

Index

Examples

Constants

View Source
const (
	EndpointUsers             auth.EndpointID = "admin.users"
	EndpointSearchUsers       auth.EndpointID = "admin.search-users"
	EndpointUser              auth.EndpointID = "admin.user"
	EndpointBan               auth.EndpointID = "admin.ban"
	EndpointUnban             auth.EndpointID = "admin.unban"
	EndpointRevokeSessions    auth.EndpointID = "admin.revoke-sessions"
	EndpointSetAdmin          auth.EndpointID = "admin.set-admin"
	EndpointImpersonate       auth.EndpointID = "admin.impersonate"
	EndpointStopImpersonating auth.EndpointID = "admin.stop-impersonating"
	EndpointUpdateUser        auth.EndpointID = "admin.update-user"
	EndpointSessions          auth.EndpointID = "admin.sessions"
	EndpointRevokeSession     auth.EndpointID = "admin.revoke-session"
	EndpointRemoveUser        auth.EndpointID = "admin.remove-user"
	EndpointCreateUser        auth.EndpointID = "admin.create-user"
	EndpointSetPassword       auth.EndpointID = "admin.set-password"
	EndpointSetEmail          auth.EndpointID = "admin.set-email"
	EndpointAudit             auth.EndpointID = "admin.audit"
)

Operation ids. They are permanent: rate rules, hooks and every transport route address an operation by id, so renaming one breaks deployed clients.

View Source
const (
	ColGrantedAt    = "admin_granted_at"
	ColBannedAt     = "admin_banned_at"
	ColBanReason    = "admin_ban_reason"
	ColBanExpiresAt = "admin_ban_expires_at"
)

Columns this plugin adds to auth_user, prefixed with the plugin name so no other plugin's column can collide with them.

View Source
const (
	// DefaultPageSize is the page an admin screen gets when it names none
	// and the app set no auth.WithDefaultPageSize: one screenful of rows.
	//ds:def id=defaultpagesize-k5wq5psj owner=@khanakia stability=stable
	DefaultPageSize = 50
	// MaxPageSize caps a requested page, so one request cannot read the whole
	// user table; a larger limit is clamped rather than refused.
	//ds:def id=maxpagesize-9qpk2wp4 owner=@khanakia stability=stable
	MaxPageSize = 200
)

Paging bounds for the user list.

View Source
const (
	// MaxSearchLength is the longest search value, in characters: the widest
	// text column the search reads (an address in auth_identity.value) holds
	// 320 characters on MySQL, so a longer fragment can match nothing.
	//ds:def id=maxsearchlength-r7d4kq2n owner=@khanakia stability=stable
	MaxSearchLength = 320
	// DefaultSearchLimit and DefaultSearchWindow bound search-users per client
	// IP: 30 searches a minute is a person typing into a search box, and keeps
	// a stolen admin session from walking the user table by fragment at
	// machine speed. SearchRate replaces them. Go API calls carry no IP and
	// are not counted, as with every ByIP rule.
	//ds:def id=defaultsearchlimit-p3w8zn6c owner=@khanakia stability=stable
	DefaultSearchLimit  = 30
	DefaultSearchWindow = time.Minute
)

Search bounds and its rate rule.

View Source
const (
	ResourceUser    = "user"
	ResourceSession = "session"
	// ResourceTwoFactor is another user's second factor: read it, pause it,
	// switch it back on, or reset it so the user enrols again. Its own
	// resource rather than more user actions, so a support role can be
	// granted it without ban or set-password.
	ResourceTwoFactor = "two-factor"
)

Resources of DefaultStatements.

View Source
const (
	ActionCreate            = "create"
	ActionList              = "list"
	ActionSetRole           = "set-role"
	ActionBan               = "ban"
	ActionImpersonate       = "impersonate"
	ActionImpersonateAdmins = "impersonate-admins"
	ActionDelete            = "delete"
	ActionSetPassword       = "set-password"
	ActionSetEmail          = "set-email"
	ActionGet               = "get"
	ActionUpdate            = "update"
	ActionRevoke            = "revoke"
	// ActionEnable, ActionDisable and ActionReset are the two-factor
	// actions beside ActionGet: switch a paused factor back on, pause it,
	// and remove it with its recovery codes.
	ActionEnable  = "enable"
	ActionDisable = "disable"
	ActionReset   = "reset"
)

Actions of DefaultStatements, better-auth's names.

View Source
const (
	// RoleAdmin may do every admin operation except impersonate another
	// admin, which stays opt-in (AllowImpersonatingAdmins, or a role with
	// impersonate-admins).
	RoleAdmin = "admin"
	// RoleUser grants nothing: the role of everyone else.
	RoleUser = "user"
)

Built-in role names.

View Source
const (
	EndpointSetRole       auth.EndpointID = "admin.set-role"
	EndpointHasPermission auth.EndpointID = "admin.has-permission"
)

Operation ids of the role operations.

View Source
const (
	EndpointTwoFactor        auth.EndpointID = "admin.two-factor"
	EndpointDisableTwoFactor auth.EndpointID = "admin.disable-two-factor"
	EndpointEnableTwoFactor  auth.EndpointID = "admin.enable-two-factor"
	EndpointResetTwoFactor   auth.EndpointID = "admin.reset-two-factor"
)

Operation ids of the two-factor routes.

View Source
const (
	DataName  = "name"
	DataImage = "image"
)

Keys of update-user's data that are the profile fields, set as Name and Image are.

View Source
const ColRole = "admin_role"

ColRole is the column holding a user's admin roles.

View Source
const DefaultBannedMessage = "This account is banned."

DefaultBannedMessage is what a banned user reads at sign-in when BannedMessage is not configured.

View Source
const EndpointAllSessions auth.EndpointID = "admin.all-sessions"

EndpointAllSessions lists every live session of every user, for an operator's session monitor (better-auth's dashboard view of all sessions). One session is ended with revoke-session and all of a user's with revoke-sessions, as before.

View Source
const FilterListSeparator = ","

FilterListSeparator splits the value of in and not_in: "a,b" is the two values a and b. A query string carries one value per parameter, so the list is one comma-joined value.

View Source
const MaxFilterValues = MaxPageSize

MaxFilterValues bounds the values of one in or not_in filter: a page of MaxPageSize ids is the largest list an admin screen has a reason to send, and the bound keeps one request from becoming a statement with thousands of parameters.

View Source
const PluginName = "admin"

PluginName is the plugin's permanent identity.

Variables

Typed columns on auth_user, for an app that filters or shows users by admin or ban state in its own queries.

View Source
var (
	// ErrForbidden refuses a caller who is not an admin. It is checked before
	// the target is looked up, so it never reveals whether a user exists.
	ErrForbidden = auth.NewError("admin.forbidden", http.StatusForbidden, "Only an administrator can do this.")
	// ErrAdminTarget refuses to ban or impersonate an admin: a stolen admin
	// session must not be able to lock out or act as the other admins.
	// AllowImpersonatingAdmins lifts it for impersonation only.
	ErrAdminTarget = auth.NewError("admin.admin_target", http.StatusConflict, "Remove this user's admin rights first.")
	// ErrBannedTarget refuses to impersonate a banned user, whose sessions the
	// ban exists to stop.
	ErrBannedTarget = auth.NewError("admin.banned_target", http.StatusConflict, "This user is banned.")
	// ErrLastAdmin refuses to remove the last admin, which would leave nobody
	// able to grant the rights back over HTTP.
	ErrLastAdmin = auth.NewError("admin.last_admin", http.StatusConflict, "Keep at least one administrator.")
	// ErrBadSort refuses a users sort outside UserSortValues.
	ErrBadSort = auth.NewError("admin.bad_sort", http.StatusBadRequest, "This sort order does not exist.")
	// ErrBadFilter refuses a users filter that is neither FlagTrue, FlagFalse
	// nor empty.
	ErrBadFilter = auth.NewError("admin.bad_filter", http.StatusBadRequest, "A filter is true, false or absent.")
	// ErrBanned refuses a banned user at sign-in, on every sign-in method. It
	// is raised only after the credential was proven (the session is about to
	// be created), so it tells a stranger nothing: without the password, the
	// code or the link they never get this far. BannedMessage changes the text.
	ErrBanned = auth.NewError("admin.banned", http.StatusForbidden, DefaultBannedMessage)
	// ErrNoPasswordPlugin refuses set-password, and create-user with a
	// password, when no registered plugin can store one.
	ErrNoPasswordPlugin = auth.NewError("admin.no_password_plugin", http.StatusConflict, "No password plugin is registered.")
	// ErrNoEmailPlugin refuses set-email and create-user when no registered
	// plugin can give a user an email address.
	ErrNoEmailPlugin = auth.NewError("admin.no_email_plugin", http.StatusConflict, "No email plugin is registered.")
	// ErrEmailRequired refuses create-user without an address: a user with no
	// identifier could never sign in or be found.
	ErrEmailRequired = auth.NewError("admin.email_required", http.StatusBadRequest, "An email address is required.")
	// ErrNoAuditPlugin refuses the audit route when no audit log with a read
	// side (EventQuerier) is registered: there is no log to read.
	ErrNoAuditPlugin = auth.NewError("admin.no_audit_plugin", http.StatusConflict, "No audit plugin is registered.")
	// ErrBadSearchField refuses a search field outside SearchFieldValues.
	ErrBadSearchField = auth.NewError("admin.bad_search_field", http.StatusBadRequest, "This search field does not exist.")
	// ErrBadSearchOperator refuses a search operator outside
	// SearchOperatorValues.
	ErrBadSearchOperator = auth.NewError("admin.bad_search_operator", http.StatusBadRequest, "This search operator does not exist.")
	// ErrSearchValue refuses an empty search, which would be the plain list
	// under another name, and one longer than MaxSearchLength, which no
	// stored value can contain.
	ErrSearchValue = auth.NewError("admin.search_value", http.StatusBadRequest, "Search for one to 320 characters.")
	// ErrSelfTarget refuses an admin impersonating themselves once
	// AllowImpersonatingAdmins is on (without it, ErrAdminTarget answers
	// first): it shows nothing new and would only rotate their own session.
	ErrSelfTarget = auth.NewError("admin.self_target", http.StatusConflict, "You cannot do this to yourself.")
)

Errors a client may see. Each carries a stable code for programmatic checks.

View Source
var (
	// ErrBadFilterField refuses a filter field outside FilterFieldValues.
	ErrBadFilterField = auth.NewError("admin.bad_filter_field", http.StatusBadRequest, "This filter field does not exist.")
	// ErrBadFilterOperator refuses an operator outside the field's
	// FilterOperatorsOf.
	ErrBadFilterOperator = auth.NewError("admin.bad_filter_operator", http.StatusBadRequest, "This filter operator does not apply to this field.")
	// ErrBadFilterValue refuses a value the field cannot hold: not true or
	// false for a flag, not an RFC 3339 time for a time, empty, longer than
	// MaxSearchLength, or more than MaxFilterValues values for in and not_in.
	ErrBadFilterValue = auth.NewError("admin.bad_filter_value", http.StatusBadRequest, "This filter value does not fit the field.")
)

Errors of the list filter.

View Source
var (
	// ErrNoUserData refuses a data object with no keys, better-auth's
	// NO_DATA_TO_UPDATE: an empty edit is a client mistake, not a success.
	ErrNoUserData = auth.NewError("admin.no_user_data", http.StatusBadRequest, "There is nothing to update.")
	// ErrFieldNotEditable refuses a data key that is neither a profile field
	// nor an EditableColumns column. Passwords, addresses and roles have
	// their own operations (set-password, set-email, set-role), where their
	// rules live, so they are refused here too.
	ErrFieldNotEditable = auth.NewError("admin.field_not_editable", http.StatusBadRequest, "This field cannot be changed here.")
	// ErrBadUserData refuses a data value of the wrong type, or a name or
	// image that is not a string.
	ErrBadUserData = auth.NewError("admin.bad_user_data", http.StatusBadRequest, "A value does not fit its field.")
)

Errors of update-user's data.

DefaultStatements is better-auth's admin vocabulary, plus the two-factor resource: every permission the plugin asks for. An access control built for accessadmin starts from it.

View Source
var (
	// ErrBadRole is a role the plugin does not define, or no role at all.
	ErrBadRole = auth.NewError("admin.bad_role", http.StatusBadRequest, "This role does not exist.")
)

Errors of the role operations.

View Source
var ErrNoAuthorizer = errors.New("admin: WithAuthorizer needs an authorizer")

ErrNoAuthorizer was WithAuthorizer(nil).

Deprecated: no longer returned. WithAuthorizer(nil) fails auth.New with auth.ErrNilOption naming the option, as every option given nil does.

View Source
var ErrNoTwoFactorPlugin = auth.NewError("admin.no_two_factor_plugin", http.StatusConflict, "No two-factor plugin is registered.")

ErrNoTwoFactorPlugin refuses the two-factor routes when no registered plugin manages second factors (TwoFactorManager). It is answered after the permission check, so it tells a caller without the permission nothing.

View Source
var ErrNotImpersonating = auth.NewError("admin.not_impersonating", http.StatusConflict, "This session is not an impersonation.")

ErrNotImpersonating is returned by stop-impersonating on an ordinary session.

View Source
var ErrSessionsNotListable = auth.NewError("admin.sessions_not_listable", http.StatusConflict, "Sessions are not kept in the database, so they cannot be listed together.")

ErrSessionsNotListable refuses all-sessions when sessions do not live in the database: a memory, Redis or custom store (auth.WithSessionStore), a mirror of two stores, or stateless sessions keep no table to page through, and an empty page would read as "nobody is signed in".

FilterFieldValues is the canonical list of filter fields.

FilterOperatorValues is the canonical list of filter operators.

View Source
var FlagValues = []Flag{FlagTrue, FlagFalse}

FlagValues are the two set values; empty is the third state.

View Source
var SearchFieldValues = []SearchField{SearchEmail, SearchName}

SearchFieldValues is the canonical list of search fields.

SearchOperatorValues is the canonical list of search operators.

View Source
var SortDirectionValues = []SortDirection{SortAsc, SortDesc}

SortDirectionValues is the canonical list of sort directions.

SortFieldValues is the canonical list of sort fields.

UserRole is the typed admin_role column.

UserSortValues is the canonical list of sort orders.

Functions

func Bindings

func Bindings(c *AdminColumns) []sqlb.Binding

Bindings binds AdminColumns, for a composite user query or LoadUserColumns.

func DefaultRoles

func DefaultRoles() map[string]Permissions

DefaultRoles returns the built-in roles, the default Authorizer's: RoleAdmin may do everything in DefaultStatements except impersonate-admins, which stays opt-in; RoleUser grants nothing. A new map each call.

func Grants

func Grants(granted, want Permissions, connector Connector) bool

Grants reports whether granted covers want under connector, better-auth's role.authorize for a request requiring every listed action: a resource granted nothing fails at once whatever the connector, every listed action of a resource must be granted (and at least one listed), And needs every resource and Or one, and an empty request is denied because asking for nothing proves nothing. Resources are visited in sorted order, so the answer never depends on map iteration. It is exported for an Authorizer that keeps a role table of its own.

Types

type AccessRequest

type AccessRequest struct {
	// UserID is the user whose permission is decided: the caller, or
	// has-permission's userId. Empty for CheckRolePermission, which decides
	// for a role and nobody in particular.
	UserID string
	// Roles are UserID's admin roles as the plugin resolved them: the stored
	// admin_role, the first of AdminRoles for a user the staff flag or
	// AdminIDs makes an admin, or DefaultRole when that leaves none. A model
	// that does not use roles ignores them.
	Roles []string
	// Permissions are what is asked for; every action of a resource is
	// required.
	Permissions Permissions
	// Connector joins the resources; And when empty.
	Connector Connector
	// TargetUserID is the user the operation acts on (ban, set-role,
	// impersonate, …) as the request names it, or "" when there is none
	// (list, create-user, a check of oneself). It is the input's value: the
	// decision comes before the target is looked up, so a refusal never
	// reveals whether the user exists.
	TargetUserID string
	// Operation is the operation asking, or "" outside one
	// (CheckRolePermission, RolesOf callers).
	Operation auth.EndpointID
	// Call is the operation's call (client IP, session, decoded input) for
	// an attribute-based rule, or nil outside an operation. Read it; never
	// change it.
	Call *auth.Call
}

AccessRequest is one permission decision. It is a struct rather than bare strings so it can carry more context later without breaking an Authorizer: a field added here is a zero value to an implementation that ignores it.

type AdminColumns

type AdminColumns struct {
	AdminGrantedAt *time.Time
	BannedAt       *time.Time
	BanReason      string
	// BanExpiresAt is nil for a ban that lasts until unban. A ban whose
	// expiry has passed no longer blocks sessions; the row keeps the dates so
	// the history stays readable.
	BanExpiresAt *time.Time
	// Role is the stored admin roles, comma-joined; "" for none (the flag
	// and DefaultRole then decide, see RolesOf).
	Role string
}

AdminColumns is this plugin's flat part of a composite user. Flat rather than nested because the columns live on auth_user itself (ADR-0001); nil times mean "never granted" and "not banned".

type AllSessionsRequest

type AllSessionsRequest struct {
	UserID string `json:"userId,omitempty"`
	Page   int    `json:"page,omitempty,string"`
	Limit  int    `json:"limit,omitempty,string"`
	Offset int    `json:"offset,omitempty,string"`
}

AllSessionsRequest pages through every live session, most recently used first. UserID, when set, narrows the list to one user's sessions. Strings in JSON, because a Query operation's input arrives as URL query values.

type AllSessionsResult

type AllSessionsResult struct {
	Sessions []FleetSession `json:"sessions"`
	Total    int64          `json:"total"`
	Page     int            `json:"page"`
	Limit    int            `json:"limit"`
	Offset   int            `json:"offset"`
}

AllSessionsResult is one page; Total counts every match, for a pager.

type Authorizer

type Authorizer interface {
	Authorize(ctx context.Context, req AccessRequest) (bool, error)
}

Authorizer decides whether an AccessRequest is allowed. Any error refuses: the plugin never reads the boolean of a call that returned an error, 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 Ban

type Ban struct {
	UserID    string
	Reason    string
	BannedAt  time.Time
	ExpiresAt *time.Time
}

Ban describes a ban in force, for a BannedMessage function.

type BanRequest

type BanRequest struct {
	UserID  string `json:"userId"`
	Reason  string `json:"reason,omitempty"`
	Minutes int    `json:"minutes,omitempty"`
}

BanRequest bans a user. Reason is cut to whole characters that fit the column on every engine rather than refused. Minutes, when positive, makes the ban lift itself after that long; zero means DefaultBanDuration, or until unban when none is configured.

func (BanRequest) TargetUserID

func (r BanRequest) TargetUserID() string

type Connector

type Connector string

Connector says how the resources of a check combine.

const (
	// And requires every resource; the default when empty.
	And Connector = "AND"
	// Or requires one resource.
	Or Connector = "OR"
)

Connectors, better-auth's spelling.

type CreateUserRequest

type CreateUserRequest struct {
	Name     string     `json:"name,omitempty"`
	Image    string     `json:"image,omitempty"`
	Email    string     `json:"email"`
	Verified bool       `json:"verified,omitempty"`
	Password auth.Token `json:"password,omitempty"`
}

CreateUserRequest is the input of create-user: a profile, one email address, and optionally a password. Email is required because a user with no identifier could never sign in or be found. Verified true marks the address verified and primary at once, for an admin who knows it is the person's; false sends the usual verify link. Password is optional: without one the user sets theirs through the forgot flow. The password is an auth.Token so a logged request never shows it.

type CreateUserResult

type CreateUserResult struct {
	User auth.UserView `json:"user"`
}

CreateUserResult is the created user.

type EditableColumn

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

EditableColumn is one of the app's own auth_user columns (auth.WithUserColumns) that update-user may set from its data object (better-auth's update-user data). Build it with Editable; the zero value edits nothing.

func Editable

func Editable[T any](col sqlb.Expr[T], check func(T) error) EditableColumn

Editable lets an admin set col, an app column, through update-user's data object under the column's name. The JSON value must decode into T exactly (an unknown shape is ErrBadUserData), then check, when not nil, may refuse it with its own error; the write goes through auth.SetUserColumn under auth.AppName, so a column the app did not register is refused there (auth.ErrNotColumnOwner) and a plugin's columns stay that plugin's.

func (EditableColumn) Name

func (e EditableColumn) Name() string

Name is the data key the column is set by: the column name.

type EmailSetter

type EmailSetter interface {
	auth.Plugin
	SetFor(ctx context.Context, userID, address string, verified bool) error
}

EmailSetter is what the admin plugin needs from an identifier plugin to give a user an address: `*email.Plugin` has it. Found through auth.PluginOf.

type EventQuerier

type EventQuerier interface {
	auth.Plugin
	Events(ctx context.Context, q audit.Query) (audit.Page, error)
}

EventQuerier is what the admin plugin needs from an audit log to serve the audit route: the method `*audit.Plugin` already has. Found through auth.PluginOf at Init; without one the route answers ErrNoAuditPlugin.

type EventRecorder

type EventRecorder interface {
	auth.Plugin
	Record(ctx context.Context, e audit.Event) error
}

EventRecorder is what the admin plugin needs from an audit log to record a ban made from Go (BanFor), which no operation hook sees: the method `*audit.Plugin` already has. Found through auth.PluginOf at Init, so any audit implementation with this method is used, and an app without one records nothing extra.

type FilterField

type FilterField string

FilterField is the user field a list filter compares (better-auth's filterField). A closed set rather than a column name, for the reason UserSort is one: an admin filter can never read a column the admin should not see, and each field has the operators that mean something for its type (FilterOperatorsOf).

const (
	// FilterEmail compares the user's email addresses (auth_identity rows of
	// kind email), verified or not. The value is folded as addresses are
	// stored, so the comparison ignores case. The default when FilterField is
	// empty, as in better-auth.
	FilterEmail FilterField = "email"
	// FilterEmailVerified is true for a user holding at least one verified
	// address.
	FilterEmailVerified FilterField = "emailVerified"
	// FilterID compares the user id exactly.
	FilterID FilterField = "id"
	// FilterName compares the display name as stored, case included, as
	// SearchName does.
	FilterName FilterField = "name"
	// FilterRole compares the roles a user holds (RolesOf): a stored role, the
	// first of AdminRoles for a user the flag or AdminIDs makes an admin, and
	// DefaultRole for a user with neither. eq means "holds this role".
	FilterRole FilterField = "role"
	// FilterBanned is true for a user whose ban is in force, the banned=
	// flag's meaning.
	FilterBanned FilterField = "banned"
	// FilterBanReason compares the stored ban reason, case included.
	FilterBanReason FilterField = "banReason"
	// FilterBanExpires compares the end of a ban; a user with no ban end
	// (none, or a permanent one) never matches.
	FilterBanExpires FilterField = "banExpires"
	// FilterCreatedAt and FilterUpdatedAt compare the user row's times.
	FilterCreatedAt FilterField = "createdAt"
	FilterUpdatedAt FilterField = "updatedAt"
)

Filter fields, better-auth's user field names.

type FilterOperator

type FilterOperator string

FilterOperator is how a list filter compares (better-auth's filterOperator, its where operators).

const (
	// FilterEq is the default when FilterOperator is empty.
	FilterEq         FilterOperator = "eq"
	FilterNe         FilterOperator = "ne"
	FilterLt         FilterOperator = "lt"
	FilterLte        FilterOperator = "lte"
	FilterGt         FilterOperator = "gt"
	FilterGte        FilterOperator = "gte"
	FilterIn         FilterOperator = "in"
	FilterNotIn      FilterOperator = "not_in"
	FilterContains   FilterOperator = "contains"
	FilterStartsWith FilterOperator = "starts_with"
	FilterEndsWith   FilterOperator = "ends_with"
)

Filter operators. The text operators contains, starts_with and ends_with treat every character of the value as text, as search-users does.

func FilterOperatorsOf

func FilterOperatorsOf(field FilterField) []FilterOperator

FilterOperatorsOf is the operators field accepts, nil for an unknown field.

type Flag

type Flag string

Flag is a three-valued filter in a query string: FlagTrue, FlagFalse or empty for "do not filter". A string rather than *bool because the input arrives as URL query values.

const (
	FlagTrue  Flag = "true"
	FlagFalse Flag = "false"
)

Flag values.

type FleetSession

type FleetSession struct {
	ID           string    `json:"id"`
	UserID       string    `json:"userId"`
	Impersonated bool      `json:"impersonated"`
	IP           string    `json:"ip"`
	UserAgent    string    `json:"userAgent"`
	CreatedAt    time.Time `json:"createdAt"`
	LastSeenAt   time.Time `json:"lastSeenAt"`
	ExpiresAt    time.Time `json:"expiresAt"`
}

FleetSession is one row of all-sessions: the user it belongs to and what the user's own session list shows of it, plus its expiry.

type IdentityView

type IdentityView struct {
	Kind     auth.IdentifierKindName `json:"kind"`
	Value    string                  `json:"value"`
	Verified bool                    `json:"verified"`
	Primary  bool                    `json:"primary"`
}

IdentityView is one identity as an admin sees it.

type ImpersonateRequest

type ImpersonateRequest struct {
	UserID  string `json:"userId"`
	Minutes int    `json:"minutes,omitempty"`
}

ImpersonateRequest names the user to act as. Minutes is optional and capped by auth.MaxImpersonationTTL.

func (ImpersonateRequest) TargetUserID

func (r ImpersonateRequest) TargetUserID() string

type ImpersonateResult

type ImpersonateResult struct {
	User       auth.UserView `json:"user"`
	ExpiresAt  time.Time     `json:"expiresAt"`
	Token      auth.Token    `json:"-"`
	AdminToken auth.Token    `json:"-"`
}

ImpersonateResult is the user now being acted as. The session arrives as an effect, replacing the admin's own in the session cookie, while the admin's own session (under a new token) is bound to the client in the flow cookie, for stop-impersonating to re-issue. Token and AdminToken are for Go callers and bearer transports, never encoded into JSON.

type Option

type Option func(*Plugin)

Option configures the plugin; pass options to New.

func AdminIDs

func AdminIDs(ids ...string) Option

AdminIDs makes the listed users admins without a grant row, for a deploy whose operators are fixed in configuration. They may still be granted and removed like anyone else; the list only adds to what admin_granted_at says. A listed user cannot be banned, impersonated or removed, like any admin.

func AdminRoles

func AdminRoles(names ...string) Option

AdminRoles names the roles that make a user an admin (better-auth's adminRoles): protected from bans, impersonation and removal, counted for the last-admin rule, and in sync with the staff flag. ["admin"] by default; the first one is the role the flag stands for.

func AllowImpersonatingAdmins

func AllowImpersonatingAdmins() Option

AllowImpersonatingAdmins lets an admin impersonate another admin, which is refused by default (ErrAdminTarget). Everything else about impersonation stays: a fresh sign-in, the TTL and its cap, the audit record, no FreshSignIn operation while acting, and the way back to the admin's own session. The impersonation session cannot use the admin operations either, so acting as an admin never chains into acting as a third user.

The risk is the reason it is opt-in: with it, one stolen admin session can act as every other admin, so a compromise of the least careful admin is a compromise of all of them. Turn it on only where every admin is equally trusted and an admin needs to see another admin's account as they see it.

func BannedMessage

func BannedMessage(fn func(ctx context.Context, b Ban) string) Option

BannedMessage sets the text a banned user reads at sign-in, from the ban itself: "Banned for chargeback until 3 May", say. The error code stays admin.banned. An empty result falls back to DefaultBannedMessage, so a function that has nothing to say never produces a blank refusal.

func DefaultBanDuration

func DefaultBanDuration(d time.Duration) Option

DefaultBanDuration bounds a ban whose request names no minutes: it lifts itself after d. Without it such a ban lasts until unban, which is today's behaviour and stays the default.

func DefaultBanReason

func DefaultBanReason(reason string) Option

DefaultBanReason is stored when a ban request names no reason, so a list of banned users never shows an empty cell. It is cut like a given reason.

func DefaultRole

func DefaultRole(name string) Option

DefaultRole is the role of a user with none stored and no flag (better-auth's defaultRole); RoleUser by default.

func EditableColumns

func EditableColumns(cols ...EditableColumn) Option

EditableColumns declares the app columns update-user's data may set. A later declaration of the same name replaces the earlier one.

func ImpersonationTTL

func ImpersonationTTL(d time.Duration) Option

ImpersonationTTL is how long an impersonation lasts when the request names no minutes. The core still caps every impersonation at auth.MaxImpersonationTTL, so this can only shorten the default, never extend the cap.

func SearchRate

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

SearchRate replaces the per-IP limit on search-users: at most limit searches per window from one client address. auth.New refuses a limit below one, so the search can be loosened but never left unlimited.

func WithAuthorizer

func WithAuthorizer(a Authorizer) Option

WithAuthorizer replaces the built-in roles as the decider of every admin permission. nil is refused by auth.New. Implement RoleCatalog as well when set-role should accept roles other than admin and user.

type PasswordSetter

type PasswordSetter interface {
	auth.Plugin
	SetPassword(ctx context.Context, userID string, password auth.Token) error
}

PasswordSetter is what the admin plugin needs from a password plugin to set a user's password: the method `*password.Plugin` already has. Found through auth.PluginOf at Init, so the admin plugin imports no other module and an app registers nothing extra.

type PermissionRequest

type PermissionRequest struct {
	UserID      string      `json:"userId,omitempty"`
	Permissions Permissions `json:"permissions"`
	Connector   Connector   `json:"connector,omitempty"`
}

PermissionRequest is the input of has-permission: the permissions asked for, of the caller, or of UserID, which needs user:get.

type PermissionResult

type PermissionResult struct {
	Success bool `json:"success"`
}

PermissionResult is the output of has-permission.

type Permissions

type Permissions map[string][]string

Permissions are actions per resource: what a role grants, or what a check asks for ({"user": ["ban"]}). JSON-encoded it is better-auth's permission object, the body of has-permission. Every listed action of a resource is required.

func (Permissions) Clone

func (p Permissions) Clone() Permissions

Clone returns a deep copy.

type Plugin

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

Plugin is the admin plugin. Build it with New; the zero value has no operations.

func New

func New(opts ...Option) *Plugin

New builds the plugin. Who is an admin is data (admin_granted_at); the options only add fixed admins and defaults for bans and impersonations.

func (*Plugin) AllSessions

func (p *Plugin) AllSessions(ctx context.Context, in AllSessionsRequest) (AllSessionsResult, error)

AllSessions pages through every live session, as the signed-in admin.

func (*Plugin) Audit

func (p *Plugin) Audit(ctx context.Context, q audit.Query) (audit.Page, error)

Audit pages through the audit log, as the signed-in admin; see audit.Query.

func (*Plugin) Ban

func (p *Plugin) Ban(ctx context.Context, userID, reason string) error

Ban bans a user and ends their sessions, as the signed-in admin with a fresh sign-in.

func (*Plugin) BanFor

func (p *Plugin) BanFor(ctx context.Context, userID, reason string, until time.Time) error

BanFor bans userID from Go only, like Grant from trusted code with no signed-in admin: an import carrying another system's bans, a job acting on a fraud signal. A zero until is permanent; otherwise the ban lifts at until (DefaultBanDuration does not apply, since the caller states the end). The user's sessions end, as with ban. An admin cannot be banned (ErrAdminTarget), an unknown user is auth.ErrUserNotFound. When an audit log is registered (EventRecorder) the ban is recorded under EndpointBan with no actor and the user as subject, in the same transaction. It runs in one transaction, or joins the one in ctx.

func (*Plugin) CheckRolePermission

func (p *Plugin) CheckRolePermission(ctx context.Context, role string, perms Permissions) bool

CheckRolePermission reports whether the authorizer allows role (one name or several, comma-joined) every action of perms, with no database: better-auth's checkRolePermission, for a UI that shows or hides a button by role. The request has no UserID; an unknown role grants nothing and an authorizer error is false.

func (*Plugin) Columns

func (p *Plugin) Columns() []auth.ColumnExtension

Columns adds the admin and ban columns to auth_user.

func (*Plugin) CreateUser

func (p *Plugin) CreateUser(ctx context.Context, in CreateUserRequest) (auth.UserView, error)

CreateUser creates a user with an address and optionally a password, as the signed-in admin with a fresh sign-in.

func (*Plugin) DataHooks

func (p *Plugin) DataHooks() auth.DataHooks

DataHooks refuse to create a session for a banned user. The hook sits on the session write rather than on any sign-in operation, so it covers every method — password, code, link, passkey, OAuth, a pending second-factor session — and a method added later. It fires only after the method proved the credential, which is what keeps the refusal enumeration-safe: a stranger who does not hold the credential never reaches it, and sees the same wrong-credential answer as for any other account.

func (*Plugin) DisableTwoFactor

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

DisableTwoFactor pauses a user's second factor, as the signed-in admin with a fresh sign-in and two-factor:disable.

func (*Plugin) EnableTwoFactor

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

EnableTwoFactor switches a paused second factor back on, as the signed-in admin with a fresh sign-in and two-factor:enable.

func (*Plugin) Grant

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

Grant makes userID an admin, from Go only. It is how the first admin is made; afterwards admins grant each other through set-admin.

func (*Plugin) HasPermission

func (p *Plugin) HasPermission(ctx context.Context, in PermissionRequest) (bool, error)

HasPermission reports whether the caller, or in.UserID, holds the permissions (better-auth's admin hasPermission).

func (*Plugin) Impersonate

func (p *Plugin) Impersonate(ctx context.Context, in ImpersonateRequest) (ImpersonateResult, error)

Impersonate acts as another user, as the signed-in admin.

func (*Plugin) Init

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

Init captures the core and finds the plugins that can set a password and an address. Their absence is not an error here: an app without passwords still gets every other admin operation, and set-password tells the caller.

func (*Plugin) Name

func (p *Plugin) Name() string

Name identifies the plugin.

func (*Plugin) Operations

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

Operations lists every operation.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded, such as auth.ErrNilOption naming an option given nil; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) RateLimits

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

RateLimits puts the search-users rule (SearchRate) on the one admin operation that reads users by fragment.

func (*Plugin) RemoveUser

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

RemoveUser deletes a user and everything that references them, as the signed-in admin with a fresh sign-in. An admin must lose the rights first.

func (*Plugin) ResetTwoFactor

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

ResetTwoFactor removes a user's second factor and recovery codes so they enrol again, as the signed-in admin with a fresh sign-in and two-factor:reset.

func (*Plugin) ResetTwoFactorFor

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

ResetTwoFactorFor resets userID's second factor from Go only, like BanFor from trusted code with no signed-in admin: a recovery command run by an operator. Unlike the route it accepts an admin target, because it is the way back for an admin who lost their phone when no other admin may reset them over HTTP. When an audit log is registered (EventRecorder) the reset is recorded under EndpointResetTwoFactor with no actor and the user as subject, in the same transaction, so a reset that cannot be recorded does not happen. An unknown user is auth.ErrUserNotFound, a user with no factor the second-factor plugin's error (plugin/twofactor: ErrNotEnrolled).

func (*Plugin) RevokeSession

func (p *Plugin) RevokeSession(ctx context.Context, userID, sessionID string) error

RevokeSession ends one session of a user, as the signed-in admin with a fresh sign-in.

func (*Plugin) RevokeSessions

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

RevokeSessions ends every session of a user without banning them, for a suspected stolen session.

func (*Plugin) RolesOf

func (p *Plugin) RolesOf(ctx context.Context, userID string) ([]string, error)

RolesOf returns userID's roles, from Go.

func (*Plugin) SearchUsers

func (p *Plugin) SearchUsers(ctx context.Context, in SearchUsersRequest) (UsersResult, error)

SearchUsers pages through users whose address or name holds a fragment, as the signed-in admin. It has its own rate rule (SearchRate), because a fragment search is the one admin read that can walk the user table.

func (*Plugin) SessionPredicates

func (p *Plugin) SessionPredicates() []sqlb.Predicate

SessionPredicates refuses every session of a banned user. It is evaluated per lookup, so a ban with an expiry stops blocking the moment it passes.

func (*Plugin) Sessions

func (p *Plugin) Sessions(ctx context.Context, userID string) ([]auth.SessionView, error)

Sessions lists a user's sessions, as the signed-in admin.

func (*Plugin) SetAdmin

func (p *Plugin) SetAdmin(ctx context.Context, userID string, admin bool) error

SetAdmin grants or removes admin rights, as the signed-in admin; it refuses to remove the last admin.

func (*Plugin) SetRole

func (p *Plugin) SetRole(ctx context.Context, in SetRoleRequest) error

SetRole replaces a user's roles, as a caller with user:set-role.

func (*Plugin) SetRoleFor

func (p *Plugin) SetRoleFor(ctx context.Context, userID string, roles ...string) error

SetRoleFor replaces userID's roles from Go, with no caller to check: a seed or an import. The last-admin rule still applies.

func (*Plugin) SetUserEmail

func (p *Plugin) SetUserEmail(ctx context.Context, in SetEmailRequest) error

SetUserEmail gives a user an address, as the signed-in admin with a fresh sign-in; verified makes it the primary address at once.

func (*Plugin) SetUserPassword

func (p *Plugin) SetUserPassword(ctx context.Context, userID string, password auth.Token) error

SetUserPassword sets a user's password, as the signed-in admin with a fresh sign-in. The user's sessions end: whoever holds them may not be the person the password is being set for.

func (*Plugin) StopImpersonating

func (p *Plugin) StopImpersonating(ctx context.Context) (StopResult, error)

StopImpersonating ends the impersonation session in ctx and reports whether the admin's own session was re-issued (as an effect; see StopResult).

func (*Plugin) TwoFactor

func (p *Plugin) TwoFactor(ctx context.Context, userID string) (TwoFactorResult, error)

TwoFactor shows a user's second factor, as the signed-in admin with two-factor:get.

func (*Plugin) Unban

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

Unban lifts a ban and clears its reason. Sessions ended by the ban stay ended; sessions made while banned start working again.

func (*Plugin) UpdateUser

func (p *Plugin) UpdateUser(ctx context.Context, in UpdateUserRequest) error

UpdateUser changes a user's name or image, as the signed-in admin with a fresh sign-in.

func (*Plugin) User

func (p *Plugin) User(ctx context.Context, userID string) (UserDetail, error)

User shows one user, as the signed-in admin.

func (*Plugin) Users

func (p *Plugin) Users(ctx context.Context, in UsersRequest) (UsersResult, error)

Users lists users, newest first, as the signed-in admin.

func (*Plugin) Validate

func (p *Plugin) Validate(*auth.Config) error

Validate refuses a missing authorizer and role names it does not define.

type RevokeSessionRequest

type RevokeSessionRequest struct {
	UserID    string `json:"userId"`
	SessionID string `json:"sessionId"`
}

RevokeSessionRequest names one session of a user, as the input of revoke-session. The user id is required so a session id alone, which the user's own list shows, cannot be used against another user.

func (RevokeSessionRequest) TargetUserID

func (r RevokeSessionRequest) TargetUserID() string

type RoleCatalog

type RoleCatalog interface {
	DefinesRole(name string) bool
}

RoleCatalog is an optional upgrade of Authorizer: the role names it defines, which set-role accepts and AdminRoles and DefaultRole may name. An Authorizer without it accepts the built-in names (RoleAdmin, RoleUser) only, so set-role never stores a role nothing could decide on.

type Roles

type Roles []string

Roles is a list of role names that decodes from a JSON string ("a,b") or array, as better-auth's set-role sends either.

func (*Roles) UnmarshalJSON

func (r *Roles) UnmarshalJSON(b []byte) error

UnmarshalJSON accepts a string or an array of strings.

type SearchField

type SearchField string

SearchField is what search-users matches the value against. A closed set rather than a column name, for the reason UserSort is one: an admin search can never read a column the admin should not see.

const (
	// SearchEmail matches the user's email addresses (auth_identity rows of
	// kind email), verified or not. It ignores case: addresses are stored
	// folded, and the value is folded the same way before it is compared.
	// The default when Field is empty.
	SearchEmail SearchField = "email"
	// SearchName matches the display name as stored, case included. Names
	// have no normalized form, and the library folds case in Go only, never
	// with lower() in SQL, so "ada" does not find "Ada".
	SearchName SearchField = "name"
)

Search fields.

type SearchOperator

type SearchOperator string

SearchOperator is where the value must appear in the field.

const (
	// SearchContains matches the value anywhere; the default when Operator
	// is empty.
	SearchContains   SearchOperator = "contains"
	SearchStartsWith SearchOperator = "starts_with"
	SearchEndsWith   SearchOperator = "ends_with"
)

Search operators.

type SearchUsersRequest

type SearchUsersRequest struct {
	Value    string         `json:"value"`
	Field    SearchField    `json:"field,omitempty"`
	Operator SearchOperator `json:"operator,omitempty"`
	// Page, Limit, Offset, the sort and the filters are UsersRequest's.
	Page           int            `json:"page,omitempty,string"`
	Limit          int            `json:"limit,omitempty,string"`
	Offset         int            `json:"offset,omitempty,string"`
	Sort           UserSort       `json:"sort,omitempty"`
	SortBy         SortField      `json:"sortBy,omitempty"`
	SortDirection  SortDirection  `json:"sortDirection,omitempty"`
	Admin          Flag           `json:"admin,omitempty"`
	Banned         Flag           `json:"banned,omitempty"`
	FilterField    FilterField    `json:"filterField,omitempty"`
	FilterOperator FilterOperator `json:"filterOperator,omitempty"`
	FilterValue    *string        `json:"filterValue,omitempty"`
}

SearchUsersRequest is the input of search-users: a fragment of an address or a name, and the same paging, sort and filters as UsersRequest. Every character of Value is text: %, _ and the escape character match themselves, never "anything".

type SessionsResult

type SessionsResult struct {
	Sessions []auth.SessionView `json:"sessions"`
}

SessionsResult is the output of sessions: another user's sessions as the user would see them, with Current always false.

type SetAdminRequest

type SetAdminRequest struct {
	UserID string `json:"userId"`
	Admin  bool   `json:"admin"`
}

SetAdminRequest grants (Admin true) or removes admin rights. Either is a no-op when the user already has the requested state.

func (SetAdminRequest) TargetUserID

func (r SetAdminRequest) TargetUserID() string

type SetEmailRequest

type SetEmailRequest struct {
	UserID   string `json:"userId"`
	Email    string `json:"email"`
	Verified bool   `json:"verified,omitempty"`
}

SetEmailRequest is the input of set-email; see CreateUserRequest.Verified.

func (SetEmailRequest) TargetUserID

func (r SetEmailRequest) TargetUserID() string

type SetPasswordRequest

type SetPasswordRequest struct {
	UserID   string     `json:"userId"`
	Password auth.Token `json:"password"`
}

SetPasswordRequest is the input of set-password. The password plugin's rules apply, every session of the user ends, and open reset links are spent, exactly as for password.SetPassword from Go.

func (SetPasswordRequest) TargetUserID

func (r SetPasswordRequest) TargetUserID() string

type SetRoleRequest

type SetRoleRequest struct {
	UserID string `json:"userId"`
	Role   Roles  `json:"role"`
}

SetRoleRequest is the input of set-role.

func (SetRoleRequest) TargetUserID

func (r SetRoleRequest) TargetUserID() string

TargetUserID names the user for the audit log.

type SortDirection

type SortDirection string

SortDirection is better-auth's sortDirection.

const (
	SortAsc  SortDirection = "asc"
	SortDesc SortDirection = "desc"
)

Sort directions; empty is SortAsc, better-auth's default.

type SortField

type SortField string

SortField is the user field the list is sorted by (better-auth's sortBy): a closed set, as UserSort is.

const (
	SortByCreatedAt SortField = "createdAt"
	SortByUpdatedAt SortField = "updatedAt"
	SortByName      SortField = "name"
	SortByID        SortField = "id"
)

Sort fields. Email is not one: a user may hold several addresses, so "sorted by email" has no single value per row.

type StopResult

type StopResult struct {
	OK       bool `json:"ok"`
	Restored bool `json:"restored"`
}

StopResult is the output of stop-impersonating. Restored is true when the admin's own session was re-issued; false means the client was signed out, because the flow binding was gone or the admin's session had ended.

type TwoFactorManager

type TwoFactorManager interface {
	auth.Plugin
	// FactorOf is userID's confirmed factor's method ("" for none, which
	// includes an enrolment still waiting for its first code) and whether
	// sign-in asks for it.
	FactorOf(ctx context.Context, userID string) (method string, enabled bool, err error)
	// DisableFor pauses the factor: sign-in stops asking, the factor and
	// its recovery codes stay.
	DisableFor(ctx context.Context, userID string) error
	// EnableFor switches a paused factor back on.
	EnableFor(ctx context.Context, userID string) error
	// Reset removes the factor, its recovery codes and its trusted devices,
	// so the user enrols again.
	Reset(ctx context.Context, userID string) error
}

TwoFactorManager is what the admin plugin needs from a second-factor plugin to manage another user's factor: the methods `*twofactor.Plugin` already has. Found through auth.PluginOf at Init, so the admin plugin imports no second-factor package, and any implementation with these four methods is used. The methods check nobody; the admin plugin authorizes before it calls them.

type TwoFactorResult

type TwoFactorResult struct {
	// Enrolled is true when the user has a confirmed factor, on or paused.
	Enrolled bool `json:"enrolled"`
	// Enabled is true when sign-in asks for it; Enrolled and not Enabled is
	// a paused factor.
	Enabled bool `json:"enabled"`
	// Method is the factor's method as the second-factor plugin names it
	// (plugin/twofactor: "totp" or "delivered"); "" when not enrolled.
	Method string `json:"method"`
}

TwoFactorResult is the output of two-factor: what a support screen shows before it pauses or resets anything.

type UpdateUserRequest

type UpdateUserRequest struct {
	UserID string  `json:"userId"`
	Name   *string `json:"name,omitempty"`
	Image  *string `json:"image,omitempty"`
	// Data is better-auth's data object: name and image (DataName,
	// DataImage, overriding the fields above), and the app columns declared
	// with EditableColumns. Any other key is ErrFieldNotEditable, an empty
	// object ErrNoUserData; nil sets only Name and Image.
	Data map[string]json.RawMessage `json:"data,omitempty"`
}

UpdateUserRequest is the input of update-user. A nil field is left as it is; the same limits as the user's own profile update apply.

func (UpdateUserRequest) TargetUserID

func (r UpdateUserRequest) TargetUserID() string

type UserDetail

type UserDetail struct {
	User       UserSummary    `json:"user"`
	BanReason  string         `json:"banReason"`
	Identities []IdentityView `json:"identities"`
	Sessions   int            `json:"sessions"`
}

UserDetail is one user with identities and live session count, for an admin deciding whether to ban or end sessions.

type UserRequest

type UserRequest struct {
	UserID string `json:"userId"`
}

UserRequest names a user, as the input of user, unban and revoke-sessions.

func (UserRequest) TargetUserID

func (r UserRequest) TargetUserID() string

TargetUserID names the user the request is about, for the audit log (audit.Targeted). Every admin request that names a user implements it, so an event's subject is the user acted on rather than the admin acting.

type UserSort

type UserSort string

UserSort orders the users list. A closed set rather than a column name, so the list can never be sorted by a column the admin should not see.

const (
	// UserSortNewest is the default: newest sign-ups first.
	UserSortNewest   UserSort = "created_desc"
	UserSortOldest   UserSort = "created_asc"
	UserSortNameAsc  UserSort = "name_asc"
	UserSortNameDesc UserSort = "name_desc"
)

Sort orders of the users list.

type UserSummary

type UserSummary struct {
	ID        string    `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"createdAt"`
	Admin     bool      `json:"admin"`
	Banned    bool      `json:"banned"`
	// Email is the user's primary email address, or their earliest email
	// identity when none is primary yet (an address awaiting verification),
	// or "" when they have none. Loaded for the whole page in one query
	// (withEmails), never per row.
	Email string `json:"email"`
	// Roles is every role the user holds by rolesOf's rules: the stored
	// ones, the flag's admin role, or DefaultRole when they have none. Read
	// from the same row as the rest of the summary, so it costs no query.
	Roles []string `json:"roles"`
	// LastActiveAt is the user's most recent session activity (better-auth's
	// lastActiveAt), derived from the sessions rather than kept in a column
	// that every request would write; nil when no session record is left.
	// See lastActive for how far back it reaches.
	LastActiveAt *time.Time `json:"lastActiveAt"`
}

UserSummary is one row of the list: only what an admin needs to pick a user, so a page of them leaks no identifiers.

type UsersRequest

type UsersRequest struct {
	Query string `json:"query,omitempty"`
	// Page starts at 1. Strings in JSON, because a Query operation's input
	// arrives as URL query values. Offset, when set, replaces Page: it is the
	// number of rows to skip, for a client that pages by offset.
	Page   int `json:"page,omitempty,string"`
	Limit  int `json:"limit,omitempty,string"`
	Offset int `json:"offset,omitempty,string"`
	// Sort is one of UserSortValues; empty means UserSortNewest, unless
	// SortBy is set.
	Sort UserSort `json:"sort,omitempty"`
	// SortBy and SortDirection are better-auth's sortBy and sortDirection:
	// one of SortFieldValues, ascending unless SortDesc. SortBy and Sort
	// together are ErrBadSort.
	SortBy        SortField     `json:"sortBy,omitempty"`
	SortDirection SortDirection `json:"sortDirection,omitempty"`
	// Admin and Banned narrow the list to admins (or non-admins) and to users
	// whose ban is in force (or not). Empty means no filter.
	Admin  Flag `json:"admin,omitempty"`
	Banned Flag `json:"banned,omitempty"`
	// FilterField, FilterOperator and FilterValue are better-auth's one
	// field filter: the field defaults to FilterEmail and the operator to
	// FilterEq; nil FilterValue filters nothing (a field or operator that
	// does not exist is refused even then). in and not_in take values
	// joined by FilterListSeparator; a time is RFC 3339; a flag is true or
	// false.
	FilterField    FilterField    `json:"filterField,omitempty"`
	FilterOperator FilterOperator `json:"filterOperator,omitempty"`
	FilterValue    *string        `json:"filterValue,omitempty"`
}

UsersRequest pages through users. Query, when set, is an identifier (an address, a number, a handle) or a user id, matched exactly. A fragment search is search-users (SearchUsersRequest), a separate operation with its own rate rule, because matching fragments of addresses turns an admin session into an enumeration tool and that deserves its own budget.

type UsersResult

type UsersResult struct {
	Users []UserSummary `json:"users"`
	Total int64         `json:"total"`
	Page  int           `json:"page"`
	Limit int           `json:"limit"`
	// Offset is the number of rows skipped, as actually used.
	Offset int `json:"offset"`
}

UsersResult is one page. Total counts every match, for a pager; Page and Limit are the values actually used after defaults and clamping.

Jump to

Keyboard shortcuts

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