Admin and Operations
Admin-facing features: impersonation, invitation-based onboarding, user management, the persisted audit log, and the hook system.
[!WARNING] The
service.Authmethods on this page (Impersonate,UsersList, etc.) enforce no role checks themselves — if you call them directly, your application must verify the caller is allowed (e.g.caller.HasRole("admin")) first. If you're usingHandler's built-in HTTP routes instead, that layer does gate this subtree by default (Cfg.AdminRole, defaulting to"admin", checked via the RBAC tables) — seeWithAdminAuthzto customize or disable it.
Impersonation
ezauth supports admin impersonation: an authenticated user can act as another user (e.g. for customer support debugging), then swap back to their own session.
[!IMPORTANT] The
Impersonateservice method enforces no authorization for who may impersonate (same stance as Admin User Management below) — it mints tokens for any target user on behalf of whoever calls it, so checkadminUser.HasRole("admin")(or equivalent) yourself before calling it directly.Handler's built-in/impersonateHTTP routes gate this by default instead (Cfg.AdminRole), customizable/disable-able viaWithAdminAuthz. Invitation-Based Onboarding is different: who may invite is still unchecked at both levels, but what roles an invitation can grant is enforced even at the service level (InvitationCreaterejects a role the inviter doesn't hold).An impersonation session's refresh token lives for 1 hour, not the 30 days a normal session's does —
TokenRefreshnever re-checks the acting admin's current role, only the impersonated target's, so this bounds how long an admin whose role gets revoked mid-impersonation can keep the session going via refresh.
// adminUser must already be authenticated; check authorization yourself first.
if !adminUser.HasRole("admin") {
// reject
}
tokenResp, err := auth.Service.Impersonate(ctx, adminUser, targetUserID)
// tokenResp.AccessToken / tokenResp.RefreshToken now authenticate as targetUser,
// with the access token carrying an "act" claim identifying adminUser.
// ... later, end the impersonation session (callerID must match the admin
// who started it, i.e. adminUser.ID):
err = auth.Service.StopImpersonating(ctx, adminUser.ID, tokenResp.RefreshToken)
Detecting whether the current request is an impersonation session depends on which auth mode the route uses:
// Bearer/JWT mode (requires AuthMiddleware): reads the "act" claim.
impersonatorID, err := ezauth.GetImpersonatorID(ctx)
// Cookie/session mode: reads the swapped-in session data.
adminID, isImpersonating := auth.IsImpersonating(ctx)
if isImpersonating {
admin, _ := auth.GetImpersonator(ctx) // full *models.User for adminID
fmt.Println("acting as admin:", admin.Email)
}
These two are backed by different mechanisms (JWT claims vs. session storage), so a route reachable over either transport would otherwise need to branch on which one applies. For that case, use the transport-agnostic pair instead — they check both and return whichever applies:
adminID, ok := auth.CurrentImpersonatorID(ctx) // (string, bool)
admin, err := auth.CurrentImpersonator(ctx) // (*models.User, error)
Safe to call regardless of transport: the session-manager middleware always runs first, even on Bearer-only routes, so the cookie-mode check never panics for lack of loaded session data — it just finds nothing and falls through to the JWT check.
See the Impersonation section of the README for the standalone-service (JSON API / form) equivalents.
Roles & Permissions (RBAC)
[!WARNING] SQLite deployments only, upgrading from an earlier version:
ezauthnow enables SQLite'sforeign_keyspragma (it's off by default per-connection in SQLite, unlike postgres/mysql). This is what makes the cascading deletes below actually work — but it also means everyON DELETE CASCADE/SET NULLin the schema, not just the new RBAC/organization tables, now really fires: deleting a user really cascades to their tokens/webauthn credentials, etc. (audit logs are the one exception — see below), where before this fix that cascade was a silent no-op on SQLite specifically. If your SQLite database has accumulated rows that would now get cascade-deleted, audit your data before deploying this version. Postgres and MySQL always enforced foreign keys and are unaffected.
ezauth also has real RBAC: roles/permissions tables (many-to-many, via role_permissions/user_roles join tables) plus RequireRole/RequirePermission middleware that enforce against them. This is a fully separate, additive system from the legacy comma-separated User.Roles field and its HasRole/AddRole/RemoveRole/etc. helpers — those keep working exactly as before, but RequireRole/RequirePermission consult the RBAC tables, not that field. Use whichever fits: the string field for a quick, ungoverned tag on a user; the tables when you need actual enforcement, an audit trail of grants/revokes, or permissions distinct from roles.
// One-time setup: define roles/permissions and wire them together.
role, _ := auth.Service.RoleCreate(ctx, "editor", "can edit content")
perm, _ := auth.Service.PermissionCreate(ctx, "posts:write", "write posts")
_ = auth.Service.RolePermissionGrant(ctx, "editor", "posts:write")
// Grant/revoke a role on a user — idempotent, and records an
// AuditEventRoleGranted/AuditEventRoleRevoked audit event (see Audit Log
// below) with adminUser.ID as the actor.
_ = auth.Service.UserRoleGrant(ctx, adminUser.ID, user.ID, "editor")
_ = auth.Service.UserRoleRevoke(ctx, adminUser.ID, user.ID, "editor")
// Check directly, or gate a route with the middleware.
has, _ := auth.Service.UserHasRole(ctx, user.ID, "editor")
has, _ = auth.Service.UserHasPermission(ctx, user.ID, "posts:write") // resolved transitively through the user's roles
router.Handle("/admin/posts", auth.RequireRole("editor")(postsHandler))
router.Handle("/admin/posts", auth.RequirePermission("posts:write")(postsHandler))
RequireRole/RequirePermission read the authenticated user ID from request context (set by AuthMiddleware or LoadUserMiddleware/SessionMiddleware), so they must run downstream of one of those; a missing user returns 401, a missing role/permission returns 403. Deleting a role or permission cascades: matching user_roles/role_permissions assignment rows are removed automatically.
See the Roles & Permissions section of the README for the standalone-service (JSON API / form) equivalents.
Organizations
Lightweight multi-tenancy: organizations/teams, with each member holding one role per organization — drawn from the same RBAC role catalog RequireRole checks against (a role is just an ezauth_roles row; org membership is ezauth_org_members, mapping (org, user) → role), except Cfg.AdminRole itself: OrgMemberAdd always refuses to grant it, so org-scoped membership can't be used to escalate to application-wide admin. Kept deliberately minimal — no settings/billing/invitations — a consuming app that needs more can extend via its own table FK'd to ezauth_organizations. Org membership rows cascade-delete like everything else in the schema — see the SQLite foreign-key note at the top of Roles & Permissions (RBAC) if you're upgrading an existing SQLite deployment.
Like every other RBAC-gated method in this package, the org service methods themselves (OrganizationGetByID, OrgMemberAdd, OrganizationDelete, ...) perform no membership check — they rely entirely on the HTTP-gate layer (the default adminAuthz, or your own via WithAdminAuthz) for authorization. See below for scoping a route to an organization's actual members instead of (or in addition to) a blanket global-admin gate.
org, err := auth.Service.OrganizationCreate(ctx, "Acme Inc")
// OrgMemberAdd upserts: calling it again for the same (org, user) updates the role.
err = auth.Service.OrgMemberAdd(ctx, org.ID, user.ID, "editor")
err = auth.Service.OrgMemberRemove(ctx, org.ID, user.ID)
members, err := auth.Service.OrgMembersList(ctx, org.ID) // []*models.OrgMember, RoleName joined in
orgs, err := auth.Service.UserOrganizationsList(ctx, user.ID) // organizations this user belongs to
Resolving the "current org" for a request mirrors how LoadUserMiddleware/GetSessionUser resolve the current user — ezauth doesn't presume how an org is identified (URL param, subdomain, header, etc.), so you supply an OrgLoader:
r.Use(auth.OrgLoaderMiddleware(func(ctx context.Context) (*models.Organization, error) {
orgID := chi.URLParam(r, "orgID") // or a subdomain, header, etc. — your choice
return auth.Service.OrganizationGetByID(ctx, orgID)
}))
// *models.Organization, set by OrgLoaderMiddleware. This is the package-level
// helper — ezauth.GetSessionOrg(ctx) — not a method on the *EzAuth instance.
org, err := ezauth.GetSessionOrg(ctx)
Compose OrgLoaderMiddleware with RequireOrgMembership/RequireOrgRole if a route needs to enforce the current org member's (or an exact) role, instead of (or in addition to) a blanket admin gate.
See the Organizations section of the README for the standalone-service (JSON API / form) equivalents.
Invitation-Based Onboarding
An existing user invites someone by email; the invitee gets a link that pre-fills registration with their email pre-verified and, optionally, a pre-assigned role. ezauth enforces no authorization on who may invite (same stance as Impersonate) — check that yourself before calling it. It does enforce authorization on what roles an invitation can grant: InvitationCreate rejects (service.ErrCannotGrantRole) any role in Roles the inviter doesn't already hold via the RBAC roles/permissions tables (UserHasRole, not the legacy User.Roles field), so an invitation can never grant a role its creator doesn't have. InvitationAccept grants the invited roles the same way (UserRoleGrant, attributed to the original inviter). Data remains fully opaque to ezauth, carried through to the created account unchecked.
info, err := auth.Service.InvitationCreate(ctx, inviter, service.RequestInvitation{
Email: "newperson@example.com",
Roles: "member",
Data: map[string]any{"org_id": "org-123"},
})
// The invitee later submits a password from the emailed link:
user, tokens, err := auth.Service.InvitationAccept(ctx, service.RequestInvitationAccept{
Token: tokenFromLink,
Password: "their-chosen-password",
})
invitations, err := auth.Service.Invitations(ctx, inviter.ID)
err = auth.Service.InvitationRevoke(ctx, inviter, invitations[0].ID)
EZAUTH_INVITATION_TTL (default 7 days) controls how long an invitation stays valid. EZAUTH_INVITATION_ACCEPT_PAGE_URL (Pages.InvitationAccept) is where GET /auth/invitation/accept?token=... redirects, with the token preserved as a query param.
Admin User Management
Beyond impersonation, ezauth exposes admin-facing methods to list/search/filter users, suspend/reactivate an account, and view a user's auth history. These service methods enforce no authorization on who may call them (same stance as Impersonate above) — check that yourself before calling them directly. Handler's built-in HTTP routes gate this subtree by default instead (Cfg.AdminRole), customizable/disable-able via WithAdminAuthz.
result, err := auth.Service.UsersList(ctx, service.ListUsersOptions{
Search: "alice",
Status: models.UserStatusSuspended, // "active" | "locked" | "suspended"
Limit: 20,
})
// result.Users (PasswordHash stripped), result.HasMore
user, err := auth.Service.UserSuspend(ctx, targetUserID)
user, err = auth.Service.UserReactivate(ctx, targetUserID)
history, err := auth.Service.UserAuthHistory(ctx, targetUserID, 50)
ListUsersOptions also supports CreatedAfter/CreatedBefore and LastActiveAfter/LastActiveBefore (*time.Time) for date-range filtering. UserStatusActive/Locked/Suspended are derived from the existing IsActive/lockout columns: locked is a temporary, auto-expiring brute-force lockout (see Account Lockout); suspended is UserSuspend's permanent-until-reactivated deactivation. UserAuthHistory is a lightweight proxy built from the Tokens table every other feature writes to — for a real persisted audit trail of named security events, see Audit Log.
Audit Log
ezauth persists a row to an audit log for security-relevant events — login success/failure, password reset, impersonation start/stop, account lockout, MFA enable/disable, user create/delete — automatically, via a built-in hook that wraps whatever Hook you register (see Hooks) so it keeps working whether or not you set your own. Enabled by default; disable with EZAUTH_AUDIT_LOG_ENABLED=false.
result, err := auth.Service.AuditLogs(ctx, targetUserID, service.ListAuditLogsOptions{
EventType: models.AuditEventLoginFailed, // optional, e.g. "login.failed"
Since: &since, // optional, RFC3339
Limit: 50,
})
// result.Events ([]*models.AuditLog: user_id, event_type, metadata, created_at), result.HasMore
Event types are the models.AuditEvent* constants (e.g. AuditEventLoginSucceeded, AuditEventAccountLocked). Login failures and account lockouts each get their own hook method — AfterLoginFailed and AfterAccountLocked — so you can react to them; embed DefaultHook as usual and only override what you need. Role grants/revokes are recorded too (AuditEventRoleGranted/AuditEventRoleRevoked, fired by UserRoleGrant/UserRoleRevoke). "Email verification" isn't recorded yet since ezauth doesn't have an email-verification-confirm flow.
AuditLog.UserID is *string, not string: unlike every other user-owned table, deleting a user does not cascade-delete their audit-log rows — the foreign key is ON DELETE SET NULL, so the row survives with UserID set to nil. The event still happened and is still evidence, even once the account itself is gone; erasing it at exactly the moment an account is deleted would defeat the point of an audit trail.
For the JSON API: GET /auth/api/admin/users/{id}/audit-logs (query params event_type, since/until as RFC3339 timestamps, limit/offset; default 50, max 200) — same "no authz check, caller's responsibility" stance as the rest of Admin User Management. Cookie clients use the same route under /auth/admin/users/{id}/audit-logs.
Hooks
ezauth provides a hook system that lets you intercept auth lifecycle events. This is useful for:
- Validating input before user creation (e.g., checking a banned domains table)
- Sending welcome emails or audit logs after registration
- Notifying admins of new user registrations
- Audit logging of sign-ins, sign-outs, and account deletion
Defining a Hook
Embed service.DefaultHook and override only the methods you need:
type MyHook struct {
service.DefaultHook
db *sql.DB
log *slog.Logger
}
// BeforeUserCreated runs before a new user is persisted.
// Return an error to abort the operation.
func (h MyHook) BeforeUserCreated(ctx context.Context, u *models.User) error {
var banned bool
err := h.db.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM banned_domains WHERE domain = ?)",
emailDomain(u.Email),
).Scan(&banned)
if err != nil {
return err
}
if banned {
return errors.New("email domain is not allowed")
}
return nil
}
// AfterUserCreated runs after a user has been successfully persisted.
func (h MyHook) AfterUserCreated(ctx context.Context, u *models.User) error {
// Audit log
_, err := h.db.ExecContext(ctx,
"INSERT INTO audit_log (event, user_id, ts) VALUES (?, ?, ?)",
"user.created", u.ID, time.Now(),
)
if err != nil {
return err
}
// Send welcome email (async — no extra framework needed)
go h.sendWelcomeEmail(u.Email)
h.log.InfoContext(ctx, "new user registered", "id", u.ID, "email", u.Email)
return nil
}
// AfterUserSignedIn can be used for login notifications or audit trails.
func (h MyHook) AfterUserSignedIn(ctx context.Context, u *models.User) error {
h.log.InfoContext(ctx, "user signed in", "id", u.ID, "email", u.Email)
return nil
}
Available Hooks
| Hook | Timing | Abortable |
|---|---|---|
BeforeUserCreated |
Before creating a new user | Yes (return error) |
AfterUserCreated |
After a new user is persisted | No (errors are logged) |
BeforeUserUpdated |
Before updating a user | Yes (return error) |
AfterUserUpdated |
After a user is updated | No (errors are logged) |
BeforeUserDeleted |
Before deleting a user | Yes (return error) |
AfterUserDeleted |
After a user is deleted | No (errors are logged) |
AfterUserSignedIn |
After a successful sign-in | No (errors are logged) |
AfterUserSignedOut |
After a successful sign-out | No (errors are logged) |
AfterPasswordResetRequested |
After a password reset is requested | No (errors are logged) |
AfterPasswordResetConfirmed |
After a password reset is confirmed | No (errors are logged) |
AfterOAuth2SignedIn |
After an existing user signs in via OAuth2 | No (errors are logged) |
AfterOAuth2Created |
After a new user is created via OAuth2 | No (errors are logged) |
AfterImpersonationStarted |
After an admin begins impersonating a user | No (errors are logged) |
AfterImpersonationEnded |
After an impersonation session ends | No (errors are logged) |
AfterMFAEnabled |
After a user enables TOTP MFA | No (errors are logged) |
AfterMFADisabled |
After a user disables TOTP MFA | No (errors are logged) |
AfterLoginFailed |
After a failed login attempt (known user) | No (errors are logged) |
AfterAccountLocked |
After an account is locked out | No (errors are logged) |
Every After*/outcome hook above (except AfterUserUpdated) also feeds the built-in Audit Log — your own hook and audit persistence both run, regardless of which Hook you register. The Before* hooks and AfterUserUpdated only run your code; they don't persist an audit row on their own (role grants/revokes are audited separately by UserRoleGrant/UserRoleRevoke).
Registering the Hook
auth.SetHook(MyHook{
db: sqlDB,
log: slog.Default(),
})
It's safe to call SetHook at any point — including after the server is running. Use auth.Hook() to read back the currently registered Hook.
It's safe to call SetHook at any point — including after the server is running.