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.