stale

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

README

github.com/ubgo/auth/plugin/stale

Finds accounts nobody has signed in to for a long time, mails their owners, and, only if you ask for it, deletes the accounts that never answered. A dormant account is an attack surface (a forgotten password reused elsewhere, an address that now belongs to someone else) and, in many jurisdictions, personal data that should not be kept for nothing. The guide is docs/plugins/stale.md.

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

a, err := auth.New(
	auth.WithDB(db, sqlb.PostgresDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithSecret(secret),
	auth.WithDeliveryOutbox(), // needed for DeleteAfterWarning; sealed under a key derived from WithSecret
	auth.WithPlugins(
		email.New(email.Login(), email.Recover(), email.WithMailer(m)),
		password.New(),
		stale.New(stale.Since(installedAt), stale.DeleteAfterWarning(30*24*time.Hour)),
	),
)

Nothing runs by itself: the plugin has no goroutine and no timer. Call it from your own scheduler, in any of three ways:

  • myapp auth maintain (--dry-run first), through authcli;
  • a.Maintain(ctx, auth.MaintainOptions{}), which runs every plugin with a maintenance job and reports stale.warned, stale.deleted and the other counters;
  • p.Run(ctx, stale.RunOptions{}), which returns a stale.Result.

Each run is idempotent: it warns an account once, deletes it once, and a second run straight after finds nothing to do. examples/stale-accounts is a runnable program with a test.

What a run does

For an account inactive for InactiveAfter:

  1. Warn. A message with purpose stale.PurposeStaleAccount goes to the user's best recovery channel (a.DeliverRecovery: the first recovery kind, in plugin order, for which the user has a verified identity, such as an email address or phone number), and the account is marked (stale_warned_at), in one transaction. When deletion is on, the message says by when the account will be deleted.
  2. Wait. Any sign-in clears the mark, so the account is judged afresh the next time it goes quiet. A live session still in use counts as activity too: a user kept signed in by a long session is not inactive. An admin's impersonation is not the user's activity.
  3. Delete (only under DeleteAfterWarning). Once the grace period after the warning has passed with no sign-in, a final message (stale.PurposeAccountDeleted) is queued in the delivery outbox and the account is deleted, in one transaction: neither happens without the other.

The last activity is the newest of: the last sign-in the plugin recorded (stale_active_at, stamped on every session created from the day the plugin was installed), the account's creation, and Since. Accounts that predate the plugin have no recorded sign-in, so install it with Since set to the install time, or the first run will treat every old account as inactive since it was created.

Options

Option What it does Default
stale.New(opts...) The plugin
stale.InactiveAfter(d) How long an account may go without activity before its owner is warned; at least MinInactiveAfter (7 days) DefaultInactiveAfter, 365 days
stale.DeleteAfterWarning(grace) Turns deletion on: an account still unanswered grace after its warning is deleted, with a final message. grace is at least MinDeletionGrace (7 days). Needs auth.WithDeliveryOutbox; auth.New refuses it without off
stale.Since(t) The earliest time inactivity counts from no floor
stale.Exempt(fn) stale.ExemptFunc(ctx, userID) (bool, error): accounts never warned or deleted (an admin, an organization owner, a billing contact). An error counts the account as failed for that run none
stale.BatchSize(n) The most accounts one run warns, and the most it deletes, 1 to MaxBatchSize (10000), so a first run on an old install cannot mail the whole user table at once; the next run carries on DefaultBatchSize, 200

An option out of range, or Exempt(nil), makes auth.New fail naming the option.

Messages

Purpose Data Default wording
stale.PurposeStaleAccount (stale_account) stale.StaleAccountData{LastActive, InactiveDays, DeleteAt}; DeleteAt is nil when deletion is off English subject and text; the delivering plugin's templates per locale win
stale.PurposeAccountDeleted (account_deleted) stale.AccountDeletedData{WarnedAt, DeletedAt} English subject and text

Both payloads survive the delivery outbox: the plugin implements auth.MessageDataDecoder, so a template's type assertion works after a retry.

Results

Run returns a stale.Result; a.Maintain reports the same fields as counters prefixed with stale.:

Field Counter Counts
Warned warned Accounts sent the warning
Deleted deleted Accounts deleted after the grace period
Cleared cleared Warned accounts found in use after all and returned to normal
Active active Stale-looking accounts skipped because a live session is in use
Exempt exempt Accounts the ExemptFunc protected
NoChannel no_channel Accounts with no verified recovery channel to warn, which are therefore never deleted either
Failed failed Accounts that could not be processed (logged); a later run retries them

Under DryRun nothing is sent, marked or deleted. A dry run cannot know who has no recovery channel, so NoChannel stays 0 and those accounts are counted as warned.

Tables and columns

Two nullable columns on auth_user, owned by this plugin and created by a.Migrate:

Column Typed column Meaning
stale_active_at stale.ActiveAt The user's last sign-in the plugin recorded; NULL for a user who has not signed in since it was installed
stale_warned_at stale.WarnedAt When the warning was sent; NULL when it was not, or the user has signed in since

Read them in your own queries to report on dormant accounts.

Errors

The plugin answers no route, so it has no error codes. auth.New refuses DeleteAfterWarning without auth.WithDeliveryOutbox (auth.ErrBadOperation), and an option out of range. A run returns an error only when it cannot read its candidates; a failure on one account is logged, counted in Failed, and retried by the next run.

Security notes

Property Pinned by
An account is warned once after the inactive period, and not again TestStale_WarnsOnceAfterTheInactivePeriod (integration)
A sign-in clears the warning and restarts the clock TestStale_ASignInClearsTheWarningAndTheClockRestarts (integration)
Deletion is opt-in and waits for the grace period; a sign-in during it saves the account TestStale_DeletionIsOptInAndWaitsForTheGrace, TestStale_ASignInDuringTheGraceSavesTheAccount (integration)
Exempt accounts, accounts with no channel and failures are never deleted, also when protected after the warning TestStale_ExemptNoChannelAndFailures, TestStale_AnAccountProtectedAfterItsWarningIsNotDeleted (integration)
A long-lived session in use is activity; an impersonation is not TestStale_ALongLivedSessionCountsAsActivity, TestStale_AWarnedUserFoundInUseIsClearedNotDeleted, TestStale_AnImpersonationIsNotTheUsersActivity (integration)
A dry run changes nothing; the batch size caps each run; Since keeps old accounts out of the first run TestStale_DryRunCountsAndChangesNothing, TestStale_BatchSizeCapsEachRun, TestStale_SinceKeepsOldAccountsOutOfTheFirstRun (integration)
Deletion without the outbox and bad options are refused at start-up TestStale_NewRefusesWhatCannotWork (integration), TestNew_DefaultsAndOptionRefusals

Limits: two runs racing on several instances may rarely both mail one account (a duplicate message, never a missed one), because the warning is marked in the transaction that queues it. Only the user is mailed; an admin's report is the run's counters.

Example

Documentation

Overview

Package stale finds accounts nobody has signed in to for a long time, mails their owners, and, if the app asks for it, deletes the accounts that never answered. Stale accounts are an attack surface (a forgotten password reused elsewhere, an address that now belongs to someone else) and, in many jurisdictions, personal data that should not be kept for nothing.

Nothing runs by itself. The app calls Plugin.Run, or auth.Maintain, or "auth maintain" from the command line, from its own scheduler; the plugin has no goroutine and no timer. Each run is idempotent: it warns an account once, deletes it once, and a second run straight after finds nothing to do.

The steps, for an account inactive for InactiveAfter:

  1. Warn. A message (PurposeStaleAccount, English default wording, the delivering plugin's templates per locale win) goes to the user's best recovery channel, and the account is marked warned. The message says by when the account will be deleted, if deletion is on.
  2. Wait. Any sign-in clears the mark, so the account is judged afresh the next time it goes quiet. A live session still in use counts as activity too: a user kept signed in by a long session is not inactive.
  3. Delete (only under DeleteAfterWarning, off by default). Once the grace period after the warning has passed with no sign-in, a final message (PurposeAccountDeleted) is queued and the account is deleted, in one transaction: neither happens without the other.

What counts as the last activity is the newest of: the last sign-in the plugin recorded (it records one on every session created from the day it was installed), the account's creation, and Since. Accounts that predate the plugin have no recorded sign-in, so install it with Since set to the install time, or the first run will treat every old account as long inactive.

Index

Constants

View Source
const (
	// ColActiveAt is the time of the user's last recorded sign-in; NULL for a
	// user who has not signed in since the plugin was installed.
	//ds:def id=stale-colactiveat-3wq7nk5v owner=@khanakia stability=stable
	ColActiveAt = "stale_active_at"
	// ColWarnedAt is when the user was sent the stale-account warning; NULL
	// when they have not been, or have signed in since.
	ColWarnedAt = "stale_warned_at"
)

Columns this plugin adds to auth_user.

View Source
const (
	// PurposeStaleAccount warns the owner that the account has been inactive.
	PurposeStaleAccount auth.Purpose = "stale_account"
	// PurposeAccountDeleted tells the owner the account was deleted for
	// inactivity, after the warning went unanswered.
	PurposeAccountDeleted auth.Purpose = "account_deleted"
)

Message purposes this plugin sends. Their Data are StaleAccountData and AccountDeletedData.

View Source
const (
	// CounterWarned counts accounts sent the warning.
	CounterWarned = "warned"
	// CounterDeleted counts accounts deleted after the grace period.
	CounterDeleted = "deleted"
	// CounterCleared counts warned accounts returned to normal because a live
	// session was in use.
	CounterCleared = "cleared"
	// CounterActive counts stale-looking accounts skipped for a live session.
	CounterActive = "active"
	// CounterExempt counts accounts the ExemptFunc protected.
	CounterExempt = "exempt"
	// CounterNoChannel counts accounts with no recovery channel to warn.
	CounterNoChannel = "no_channel"
	// CounterFailed counts accounts that could not be processed.
	CounterFailed = "failed"
)

Counter names of Result and of auth.Maintain (prefixed there with "stale.").

View Source
const (
	// DefaultInactiveAfter is one year, a common retention line for a dormant
	// account.
	//ds:def id=stale-defaultinactiveafter-6mk2xq8n owner=@khanakia stability=stable
	DefaultInactiveAfter = 365 * 24 * time.Hour
	// MinInactiveAfter is the shortest accepted: a week. Anything shorter
	// warns people who simply went on holiday.
	MinInactiveAfter = 7 * 24 * time.Hour
	// MinDeletionGrace is the shortest time between warning and deletion: a
	// week, enough for a message to be read and acted on.
	MinDeletionGrace = 7 * 24 * time.Hour
	// DefaultBatchSize is how many accounts one run warns and how many it
	// deletes at most. It exists so a first run on an old install cannot mail
	// the whole user table at once; the next run carries on.
	//ds:def id=stale-defaultbatchsize-9vq3wn4k owner=@khanakia stability=stable
	DefaultBatchSize = 200
	// MaxBatchSize caps BatchSize.
	MaxBatchSize = 10000
)

Defaults and bounds.

View Source
const (
	// OutcomeWarned: the account was sent the inactivity warning.
	OutcomeWarned auth.RateOutcome = "warned"
	// OutcomeDeleted: the account was deleted after the grace period.
	OutcomeDeleted auth.RateOutcome = "deleted"
)

Outcomes of the SecurityStaleAccount event, in SecurityEvent.Outcome: what the run did to the account. They extend the rate outcomes, as the event documents.

View Source
const PluginName = "stale"

PluginName is the plugin's permanent identity: the owner of its columns.

Variables

Typed columns on auth_user, for an app that reports on dormant accounts in its own queries.

Functions

This section is empty.

Types

type AccountDeletedData

type AccountDeletedData struct {
	// WarnedAt is when the warning was sent.
	WarnedAt time.Time `json:"warnedAt"`
	// DeletedAt is when the account was deleted.
	DeletedAt time.Time `json:"deletedAt"`
}

AccountDeletedData is Message.Data of PurposeAccountDeleted.

type ExemptFunc

type ExemptFunc func(ctx context.Context, userID string) (bool, error)

ExemptFunc says an account must never be warned or deleted: an admin, an owner of an organization, a billing contact. An error counts the account as failed for this run, and nothing is done to it.

type Option

type Option func(*Plugin)

Option configures the plugin.

func BatchSize

func BatchSize(n int) Option

BatchSize caps how many accounts one run warns and how many it deletes (default DefaultBatchSize, at most MaxBatchSize).

func DeleteAfterWarning

func DeleteAfterWarning(grace time.Duration) Option

DeleteAfterWarning turns deletion on: an account still unanswered grace after its warning is deleted, with a final message. Off by default. grace is at least MinDeletionGrace. It needs auth.WithDeliveryOutbox, so the final message is stored in the same transaction as the deletion and sent (and retried) afterwards: without the outbox a crash could delete an account whose owner was never told. auth.New refuses the option without it.

func Exempt

func Exempt(fn ExemptFunc) Option

Exempt protects accounts from being warned or deleted.

func InactiveAfter

func InactiveAfter(d time.Duration) Option

InactiveAfter sets how long an account may go without activity before its owner is warned (default DefaultInactiveAfter, at least MinInactiveAfter).

func Since

func Since(t time.Time) Option

Since sets the earliest time an account's inactivity counts from. Install the plugin with Since set to now: accounts that existed before it have no recorded sign-in, and without Since the first run would call every one of them inactive since it was created. Zero (the default) sets no floor.

type Plugin

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

Plugin is the stale-account monitor. Build it with New.

func New

func New(opts ...Option) *Plugin

New builds the plugin.

func (*Plugin) Columns

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

Columns adds the last-activity and warned timestamps to auth_user.

func (*Plugin) DataHooks

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

DataHooks record the activity: every session created for the user's own sign-in stamps ColActiveAt and clears a warning. An impersonation is an admin's activity, not the user's.

func (*Plugin) DecodeMessageData

func (p *Plugin) DecodeMessageData(purpose auth.Purpose, raw json.RawMessage) (any, error)

DecodeMessageData restores the payloads the delivery outbox stored as JSON. Any other purpose is not this plugin's: (nil, nil). The any result is the boundary type of Message.Data and of auth.MessageDataDecoder, which carries a different type per purpose; here it is always StaleAccountData or AccountDeletedData.

func (*Plugin) Init

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

Init keeps the core handle the run and the hook use.

func (*Plugin) Maintain

func (p *Plugin) Maintain(ctx context.Context, o auth.MaintainOptions) (map[string]int64, error)

Maintain is auth.Maintainer: Run with the counters under their names.

func (*Plugin) Name

func (p *Plugin) Name() string

Name returns PluginName.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) Run

func (p *Plugin) Run(ctx context.Context, o RunOptions) (Result, error)

Run does one pass: deletes the accounts whose grace period is over (when deletion is on), then warns the newly inactive ones, each at most BatchSize. It is idempotent, safe to run from several instances at once for the counters' purposes (a warning is marked in the transaction that queues it, so two racing runs may rarely both mail one account; that costs a duplicate message, never a missed one), and never aborts for one account: a failure is logged, counted in Result.Failed and retried by the next run. An error comes only from reading the candidates.

func (*Plugin) Validate

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

Validate refuses deletion without the delivery outbox.

type Result

type Result struct {
	// Warned: accounts sent the warning.
	Warned int64
	// Deleted: accounts deleted after the grace period.
	Deleted int64
	// Cleared: warned accounts found active after all (a live session still
	// in use) and returned to normal.
	Cleared int64
	// Active: stale-looking accounts skipped because a live session is in use.
	Active int64
	// Exempt: accounts the ExemptFunc protected.
	Exempt int64
	// NoChannel: accounts with no verified recovery channel to warn, which
	// are therefore never deleted either.
	NoChannel int64
	// Failed: accounts that could not be processed (logged); a later run
	// retries them.
	Failed int64
}

Result counts what one run did (or, under DryRun, would do).

type RunOptions

type RunOptions struct {
	// DryRun counts without sending, marking or deleting. It cannot know who
	// has no verified recovery channel, so Result.NoChannel stays 0 and those
	// accounts are counted as warned.
	DryRun bool
}

RunOptions are what one run is asked to do.

type StaleAccountData

type StaleAccountData struct {
	// LastActive is the time of the user's last activity as the plugin
	// judged it.
	LastActive time.Time `json:"lastActive"`
	// InactiveDays is whole days since LastActive.
	InactiveDays int `json:"inactiveDays"`
	// DeleteAt is when the account will be deleted unless the user signs in;
	// nil when deletion is off.
	DeleteAt *time.Time `json:"deleteAt,omitempty"`
}

StaleAccountData is Message.Data of PurposeStaleAccount, for an app template to read with a type assertion.

Jump to

Keyboard shortcuts

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