Skip to content

Admin and Operations

Admin-facing features: impersonation, invitation-based onboarding, user management, the persisted audit log, and the hook system.

[!WARNING] The service.Auth methods 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 using Handler's built-in HTTP routes instead, that layer does gate this subtree by default (Cfg.AdminRole, defaulting to "admin", checked via the RBAC tables) — see WithAdminAuthz to 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 Impersonate service 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 check adminUser.HasRole("admin") (or equivalent) yourself before calling it directly. Handler's built-in /impersonate HTTP routes gate this by default instead (Cfg.AdminRole), customizable/disable-able via WithAdminAuthz. 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 (InvitationCreate rejects 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 — TokenRefresh never 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: ezauth now enables SQLite's foreign_keys pragma (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 every ON DELETE CASCADE/SET NULL in 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.