← repoman internals

internal/tokenexpiry

import "git.griefed.de/griefed/repoman/internal/tokenexpiry"
package tokenexpiry // import "git.griefed.de/griefed/repoman/internal/tokenexpiry"

Package tokenexpiry warns the people who can edit an access token before it
lapses. A daily Checker enumerates every editable token LOCATION (see Location),
probes the platform for the token's expiry through the source TokenInspector
seam, and persists the result so the warning emailer and the dashboard banner
can surface it to exactly the users allowed to edit that token. Only GitHub and
GitLab report token expiry; Gitea/Forgejo/Codeberg PATs do not expire and are
never enumerated.

FUNCTIONS

func DefaultDescribe(record *db.TokenExpiryRecord) string
    DefaultDescribe renders a human description of a token location from
    its scope, used in the warning email when no richer (config-name-aware)
    describer is set.

func OverrideScope(configID int64, platform string) string
    OverrideScope is the scope key for a config's per-platform token override.

func ScopeConfigID(scope string) (int64, bool)
    ScopeConfigID returns the config id a scope belongs to and true for a
    config scope (override or source); false otherwise. This is one half of the
    authorization hinge: a config scope's warnings go to that config's owner.

func ScopeUserID(scope string) (int64, bool)
    ScopeUserID returns the user id a scope belongs to and true for a personal
    (user) scope; false otherwise. The other half of the authorization hinge:
    a user scope's warnings go to exactly that user.

func SourceScope(configID int64, platform, namespace string) string
    SourceScope is the scope key for a config source's own token.

func UserScope(userID int64, platform, instanceURL, namespace string) string
    UserScope is the scope key for a user's account token for one account on
    one platform instance. The instance URL ("" = the platform default) AND the
    namespace ("" = the wildcard token covering any namespace there) are both
    part of the key.

    The namespace is load-bearing, not decoration: a user may hold a separate
    token per account on the same platform and instance — a personal one and
    an org one — and scope is the identity token_expiry stores under (UNIQUE,
    upserted ON CONFLICT). Leaving it out made those tokens share a key,
    so each check overwrote the previous one's record and every token but the
    alphabetically-last went untracked — expiring with no warning at all,
    which is the one thing this package exists to prevent.

func WithinThreshold(record *db.TokenExpiryRecord, threshold time.Duration, now time.Time) bool
    WithinThreshold reports whether a record represents a token whose expiry is
    known and falls within threshold of now (an already-expired token qualifies,
    its remaining time being negative). It is the single predicate the warning
    emailer and the dashboard banner share, so both agree on what "expiring"
    means.


TYPES

type Checker struct {
	// Repo persists per-scope expiry state.
	Repo *db.TokenExpiryRepo

	// Locations yields the current editable token locations (settings + configs).
	Locations func(ctx context.Context) ([]Location, error)

	// Inspect probes a location's token expiry through the source seam.
	Inspect Inspector

	// Now supplies the current time; defaults to time.Now. Injectable for tests.
	Now func() time.Time
}
    Checker runs one token-expiry pass: enumerate locations, probe each,
    persist the discovered expiry, reset the warned state on any rotation/expiry
    change so a new warning can fire, and prune rows for locations that no
    longer exist.

func (c *Checker) Check(ctx context.Context) ([]*db.TokenExpiryRecord, error)
    Check performs one full pass and returns the persisted state of every
    tracked token (for the warning emailer to act on). A probe failure preserves
    the last-known-good expiry — so a transient network blip never silently
    drops a pending warning — while recording the error for the dashboard.

type Inspector func(ctx context.Context, loc Location) (expiresAt time.Time, known bool, err error)
    Inspector probes one location's token expiry. It mirrors
    source.TokenInspector but is taken as a plain func so the package depends
    on neither source nor a concrete client, keeping the Checker unit-testable
    with a fake. known=false means "nothing to warn about" (non-expiring token);
    err is a failed probe.

type Location struct {
	Scope       string
	Platform    string
	Token       string
	InstanceURL string
	Namespace   string
}
    Location is one editable token location: where a token VALUE lives, which
    is also who may be warned about it. A global token is editable by settings
    admins (PermManageSettings); a per-config override or per-source token by
    that config's editors (PermEditConfig). Scope is the stable DB key (see
    migration 0005); Namespace/InstanceURL only steer the probe target and may
    be empty.

func EnumerateLocations(
	configs []*migrationconfig.MigrationConfig,
) []Location
    EnumerateLocations lists every editable token location whose platform
    reports expiry, deduplicated by scope. It walks the two places a CONFIG
    can still hold a token value — its per-platform OUTBOUND override
    (TokenOverrides, which inbound resolution deliberately does not read) and
    a legacy per-source token — skipping blanks and non-expiring platforms.
    The scope kind records who may edit (and so be warned about) each, the
    feature's security boundary.

    Instance-wide "global platform tokens" no longer exist; they were removed in
    multi-tenant Phase A, and account tokens are enumerated per user below.

    Per-user personal tokens are enumerated separately (see
    EnumerateUserLocations) since they are owned by a user, not a config.

func EnumerateUserLocations(tokens []UserToken) []Location
    EnumerateUserLocations turns users' account tokens into token locations
    (scope user:<id>:<platform>:<instanceURL>:<namespace>), keeping only the
    expiring platforms and skipping blanks. The instance URL steers the probe
    target (a self-hosted GitLab is queried at its own host). These warn the
    owning user; config locations (EnumerateLocations) warn the config owner.

type Mailer interface {
	IsConfigured() bool
	SendTokenExpiryWarning(ctx context.Context, toAddress, tokenLocation, platform string, expiresAt time.Time) error
}
    Mailer sends one token-expiry warning email. The email package's Sender
    satisfies it; kept as a local interface so this package doesn't depend on
    the email implementation and can be tested with a recording fake.

type UserLister interface {
	ListUsers(ctx context.Context) ([]*auth.User, error)
}
    UserLister lists user accounts for recipient resolution. db.UserRepo
    satisfies it.

type UserToken struct {
	UserID      int64
	Platform    string
	InstanceURL string
	Namespace   string // the account the token belongs to; "" = any namespace
	Token       string
}
    UserToken is one user's account token for a platform instance, the input to
    EnumerateUserLocations (a minimal shape so this package needn't import db).

type Warner struct {
	// Repo provides the persisted expiry state and owns the warned lifecycle.
	Repo *db.TokenExpiryRepo

	// Users lists user accounts; used to resolve a recipient id to its email.
	Users UserLister

	// OwnerForConfig maps a config id to its owner's user id (the editor of that
	// config's tokens). Nil or a miss means a config-scoped token has no resolved
	// recipient (left un-warned, retried next pass).
	OwnerForConfig func(configID int64) (int64, bool)

	// Mailer delivers the warning; a nil or unconfigured Mailer makes Warn a
	// no-op (dashboard-banner-only operation).
	Mailer Mailer

	// Threshold is how close to expiry triggers a warning.
	Threshold time.Duration

	// Describe renders a human description of a token location for the email
	// body. Optional; DefaultDescribe is used when nil.
	Describe func(record *db.TokenExpiryRecord) string

	// Now supplies the current time; defaults to time.Now. Injectable for tests.
	Now func() time.Time
}
    Warner emails the person who can edit a token when it nears expiry, exactly
    once per threshold crossing. It is the enforcement point of the feature's
    security rule: a personal-token (user scope) warning goes only to that user;
    a config-token (config scope) warning goes only to the config's owner.

func (w *Warner) Warn(ctx context.Context, records []*db.TokenExpiryRecord) (int, error)
    Warn emails the appropriate recipients for every record whose token is
    within the threshold and has not yet been warned, then marks it warned so
    the next pass stays silent until the token rotates (which clears the flag).
    It returns the number of emails sent. With no configured mailer it does
    nothing — the dashboard banner covers the no-SMTP case. A record with no
    eligible recipient is left un-warned so a later pass retries once someone
    gains an email/role.