MM-68419: Add expires_at to PAT data model and enforce expiry at token validation#36243
MM-68419: Add expires_at to PAT data model and enforce expiry at token validation#36243
Conversation
Adds an ExpiresAt field (int64 millis, 0 = never expires) to the UserAccessToken model and DB table, enforces expiry when a PAT is used to create a session, clamps the resulting session's ExpiresAt to the token's expiry so cached sessions also honor it, ships a background job (cleanup_expired_access_tokens) that periodically deletes expired tokens along with any sessions minted from them, and emits audit events for rejected and reaped expired tokens. Refs: MM-68419 Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>
Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>
|
||||||||||||||||
| Test | Retries |
|---|---|
| Postgres (shard 0) (Results) | |
| com/mattermost/mattermost/server/v8/channels/store/sqlstore.TestThreadStore/postgres/ThreadStorePopulation/Get_unread_reply_counts_for_thread | 1 |
| com/mattermost/mattermost/server/v8/channels/store/sqlstore.TestThreadStore/postgres/ThreadStorePopulation | 1 |
| com/mattermost/mattermost/server/v8/channels/store/sqlstore.TestThreadStore/postgres | 1 |
| com/mattermost/mattermost/server/v8/channels/store/sqlstore.TestThreadStore | 1 |
| com/mattermost/mattermost/server/v8/channels/api4.TestCreateUserAccessToken/expired_token_is_rejected_at_validation | 3 |
| com/mattermost/mattermost/server/v8/channels/api4.TestCreateUserAccessToken | 3 |
The previous variant created a live token, used it to mint a session, then backdated the row and revoked the cached session to force a re-validation. That flow was race-prone under parallel test execution and was flagged as flaky in CI. Replace it with a direct store write that persists the PAT with ExpiresAt already in the past, so createSessionForUserAccessToken is exercised deterministically on the first HTTP call and no session cache races are possible. Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>
…at-0af3 Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>
Documentation Impact Analysis — updates neededDocumentation Impact AnalysisOverall Assessment: Documentation Updates Required Changes SummaryThis PR introduces personal access token (PAT) expiry functionality to Mattermost, including a new database field, authentication behavior changes, background cleanup job, and new audit events. These changes represent significant new functionality that requires documentation across multiple guides. Documentation Impact Details
Recommended Actions
ConfidenceHigh — This PR introduces substantial new functionality for personal access tokens that affects documented behavior in multiple areas. The changes include database schema modifications, new background jobs, authentication behavior changes, and new audit events, all of which require documentation updates to keep the docs accurate and complete. |
📝 WalkthroughWalkthroughAdds persistent expiry to personal access tokens, rejects expired tokens at session creation (with audit), aligns session expiry to token expiry, schedules an hourly cleanup job that deletes expired tokens and sessions (with per-token audit), and updates stores, layers, migrations, tests, and i18n strings. Changes
Sequence DiagramsequenceDiagram
autonumber
participant Scheduler as Scheduler (Periodic)
participant Worker as Cleanup Worker
participant Store as UserAccessTokenStore
participant DB as Database
participant Audit as Audit Logger
Scheduler->>Worker: Trigger job (hourly)
Worker->>Store: GetExpiredBefore(cutoff, limit)
Store->>DB: SELECT id,user_id,expiresat WHERE expiresat>0 AND expiresat<=cutoff LIMIT ...
DB-->>Store: expired token metadata
Store-->>Worker: return token list
loop per token
Worker->>Audit: AuditEventExpireUserAccessToken (token id, user id, expires_at)
Audit-->>Worker: ack
end
Worker->>Store: DeleteByIds([ids])
Store->>DB: BEGIN TRANSACTION
Store->>DB: DELETE FROM Sessions WHERE user_access_token_id IN (...)
Store->>DB: DELETE FROM UserAccessTokens WHERE id IN (...)
DB-->>Store: rows affected
Store->>DB: COMMIT
Store-->>Worker: number deleted
Worker-->>Scheduler: complete (deletedCount / errors)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 golangci-lint (2.11.4)level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies" Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (3)
server/channels/store/sqlstore/user_access_token_store.go (2)
238-257: Consider selecting only needed columns instead of stripping Token post-fetch.The implementation fetches the full row (including the secret
Token) then blanks it in memory at lines 252-254. While functional, the secret traverses the network from DB to application unnecessarily.A more secure approach would use a dedicated select without the
Tokencolumn:Suggested optimization
func (s SqlUserAccessTokenStore) GetExpiredBefore(cutoff int64, limit int) ([]*model.UserAccessToken, error) { tokens := []*model.UserAccessToken{} - query := s.userAccessTokensSelectQuery. + query := s.getQueryBuilder(). + Select( + "UserAccessTokens.Id", + "UserAccessTokens.UserId", + "UserAccessTokens.Description", + "UserAccessTokens.IsActive", + "UserAccessTokens.ExpiresAt", + ). + From("UserAccessTokens"). Where(sq.Gt{"UserAccessTokens.ExpiresAt": 0}). Where(sq.LtOrEq{"UserAccessTokens.ExpiresAt": cutoff}). Where(sq.Eq{"UserAccessTokens.IsActive": true}). OrderBy("UserAccessTokens.ExpiresAt ASC"). Limit(uint64(limit)) if err := s.GetReplica().SelectBuilder(&tokens, query); err != nil { return nil, errors.Wrap(err, "failed to find expired UserAccessTokens") } - for _, token := range tokens { - token.Token = "" - } - return tokens, nil }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/channels/store/sqlstore/user_access_token_store.go` around lines 238 - 257, GetExpiredBefore currently fetches full UserAccessTokens rows (via userAccessTokensSelectQuery) then clears the Token field in memory; instead modify the query built in GetExpiredBefore to explicitly select only the needed columns (omitting the Token column) so the secret never travels from DB to app, e.g., adjust the select builder used by userAccessTokensSelectQuery or create a new select that excludes "UserAccessTokens.Token" before applying the Where/OrderBy/Limit and then return the tokens as-is; keep the existing filtering logic and still set the Token field blank if needed for compatibility.
279-282: Silent error swallowing forRowsAffected.If
RowsAffected()fails, the error is silently ignored anddeleteddefaults to 0. While this doesn't affect the deletion itself, the caller's log message will show 0 deletions even if tokens were deleted.Consider logging the error for debugging purposes:
Suggested improvement
deleted, rErr := res.RowsAffected() if rErr != nil { + // Log but don't fail - the deletion succeeded, we just can't report the count deleted = 0 }Or return the error if accurate counts are important for audit/metrics.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/channels/store/sqlstore/user_access_token_store.go` around lines 279 - 282, The code swallows the error from res.RowsAffected() causing the caller to see a misleading deleted=0; update the block around res.RowsAffected() (the call to RowsAffected and the deleted variable in user_access_token_store.go) to handle rErr explicitly: if rErr != nil then either log the error with context (e.g., using the store's logger like s.logger or the existing logging facility) including the SQL operation and any identifiers, or propagate/return rErr so callers can distinguish an actual failure from zero rows; ensure the log message references the operation (RowsAffected on user access token delete) and the err value instead of silently defaulting deleted to 0.server/channels/jobs/cleanup_expired_access_tokens/worker.go (1)
77-80: Unusedjob.Datainitialization.
job.Datais initialized but never populated or used before the function returns. This appears to be dead code—consider removing it unless there's a planned use case.Suggested removal
- if job.Data == nil { - job.Data = make(model.StringMap) - } return nil🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/channels/jobs/cleanup_expired_access_tokens/worker.go` around lines 77 - 80, The nil-check and initialization of job.Data (setting job.Data = make(model.StringMap)) is dead code because job.Data is never used before the function returns; remove the conditional block that checks if job.Data == nil and the allocation (or alternatively, if a use was intended, populate job.Data where the function processes job) so that the function no longer performs an unused allocation (locate the code around the early return that sets job.Data and delete that initialization).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/channels/api4/user_test.go`:
- Around line 5302-5310: The test is ambiguous because the saved UserAccessToken
lacks IsActive and the assertion only checks for a 401; update the Save call
that creates the model.UserAccessToken in this test to include IsActive: true so
it explicitly models an active token that has passed its ExpiresAt time, and
then strengthen the assertion after calling assertInvalidToken (or replace it)
to assert the specific error key "app.user_access_token.expired" rather than a
generic 401; modify the code around the Save(...) of model.UserAccessToken and
the assertInvalidToken usage to reflect these changes.
In `@server/channels/app/server.go`:
- Around line 1659-1663: The cleanup worker is registered with s.Audit before
the audit logger is initialized, so s.Audit can be nil in
cleanup_expired_access_tokens.MakeWorker; fix by initializing the audit logger
before calling initJobs() (or by ensuring s.Audit is assigned prior to
s.Jobs.RegisterJobType). Update NewServer to perform the audit initialization
step (the code that assigns s.Audit) before calling s.initJobs(), so
cleanup_expired_access_tokens.MakeWorker (and any other RegisterJobType calls)
always receive a non-nil s.Audit.
In `@server/channels/jobs/cleanup_expired_access_tokens/worker.go`:
- Around line 35-45: The audit loop currently calls
UserAccessToken().GetExpiredBefore(cutoff, auditBatchLimit) to fetch up to
auditBatchLimit tokens but then calls UserAccessToken().DeleteExpired(cutoff)
which deletes all expired tokens, leaving audit records incomplete when more
than auditBatchLimit exist; change the logic so DeleteExpired matches the
batched semantics: add a limit parameter to DeleteExpired (or implement a new
DeleteExpiredBatch(cutoff, limit)) and then loop (call GetExpiredBefore ->
create audits -> call DeleteExpiredBatch with the same limit) until
GetExpiredBefore returns zero results, ensuring each deleted batch has
corresponding audit records; update calls in this worker (and any affected store
interface methods) to use the new batch delete API.
In `@server/channels/store/sqlstore/user_access_token_store.go`:
- Around line 274-277: GetExpiredBefore applies "IsActive = true" but
DeleteExpired removes all expired tokens; update DeleteExpired to include the
same IsActive = true filter (or explicitly document/justify removing both active
and inactive tokens) so behavior is consistent. Locate the DeleteExpired
implementation (the transaction.Exec call deleting from UserAccessTokens) and
add the IsActive filter to the WHERE clause to mirror GetExpiredBefore, or
alternatively add a clear comment and/or separate method name indicating it
intentionally deletes inactive tokens as well.
---
Nitpick comments:
In `@server/channels/jobs/cleanup_expired_access_tokens/worker.go`:
- Around line 77-80: The nil-check and initialization of job.Data (setting
job.Data = make(model.StringMap)) is dead code because job.Data is never used
before the function returns; remove the conditional block that checks if
job.Data == nil and the allocation (or alternatively, if a use was intended,
populate job.Data where the function processes job) so that the function no
longer performs an unused allocation (locate the code around the early return
that sets job.Data and delete that initialization).
In `@server/channels/store/sqlstore/user_access_token_store.go`:
- Around line 238-257: GetExpiredBefore currently fetches full UserAccessTokens
rows (via userAccessTokensSelectQuery) then clears the Token field in memory;
instead modify the query built in GetExpiredBefore to explicitly select only the
needed columns (omitting the Token column) so the secret never travels from DB
to app, e.g., adjust the select builder used by userAccessTokensSelectQuery or
create a new select that excludes "UserAccessTokens.Token" before applying the
Where/OrderBy/Limit and then return the tokens as-is; keep the existing
filtering logic and still set the Token field blank if needed for compatibility.
- Around line 279-282: The code swallows the error from res.RowsAffected()
causing the caller to see a misleading deleted=0; update the block around
res.RowsAffected() (the call to RowsAffected and the deleted variable in
user_access_token_store.go) to handle rErr explicitly: if rErr != nil then
either log the error with context (e.g., using the store's logger like s.logger
or the existing logging facility) including the SQL operation and any
identifiers, or propagate/return rErr so callers can distinguish an actual
failure from zero rows; ensure the log message references the operation
(RowsAffected on user access token delete) and the err value instead of silently
defaulting deleted to 0.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 01558a0b-fada-4c59-bc64-26dc1ab25a87
📒 Files selected for processing (18)
server/channels/api4/user_test.goserver/channels/app/server.goserver/channels/app/session.goserver/channels/db/migrations/postgres/000170_add_expiresat_to_user_access_tokens.down.sqlserver/channels/db/migrations/postgres/000170_add_expiresat_to_user_access_tokens.up.sqlserver/channels/jobs/cleanup_expired_access_tokens/scheduler.goserver/channels/jobs/cleanup_expired_access_tokens/worker.goserver/channels/store/retrylayer/retrylayer.goserver/channels/store/sqlstore/user_access_token_store.goserver/channels/store/store.goserver/channels/store/storetest/mocks/UserAccessTokenStore.goserver/channels/store/storetest/user_access_token_store.goserver/channels/store/timerlayer/timerlayer.goserver/i18n/en.jsonserver/public/model/audit_events.goserver/public/model/job.goserver/public/model/user_access_token.goserver/public/model/user_access_token_test.go
| res, err := transaction.Exec("DELETE FROM UserAccessTokens WHERE ExpiresAt > 0 AND ExpiresAt <= ?", cutoff) | ||
| if err != nil { | ||
| return 0, errors.Wrap(err, "failed to delete expired UserAccessTokens") | ||
| } |
There was a problem hiding this comment.
IsActive filter inconsistency between GetExpiredBefore and DeleteExpired.
GetExpiredBefore filters IsActive = true (line 244), but DeleteExpired deletes all expired tokens regardless of IsActive status. This means disabled expired tokens will be deleted without corresponding audit records.
If this is intentional (cleaning up all expired tokens regardless of state), consider documenting it. Otherwise, align the filters:
Option: Add IsActive filter to DeleteExpired for consistency
- sessionsQuery := "DELETE FROM Sessions s USING UserAccessTokens o WHERE o.Token = s.Token AND o.ExpiresAt > 0 AND o.ExpiresAt <= ?"
+ sessionsQuery := "DELETE FROM Sessions s USING UserAccessTokens o WHERE o.Token = s.Token AND o.IsActive = TRUE AND o.ExpiresAt > 0 AND o.ExpiresAt <= ?"
- res, err := transaction.Exec("DELETE FROM UserAccessTokens WHERE ExpiresAt > 0 AND ExpiresAt <= ?", cutoff)
+ res, err := transaction.Exec("DELETE FROM UserAccessTokens WHERE IsActive = TRUE AND ExpiresAt > 0 AND ExpiresAt <= ?", cutoff)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/channels/store/sqlstore/user_access_token_store.go` around lines 274 -
277, GetExpiredBefore applies "IsActive = true" but DeleteExpired removes all
expired tokens; update DeleteExpired to include the same IsActive = true filter
(or explicitly document/justify removing both active and inactive tokens) so
behavior is consistent. Locate the DeleteExpired implementation (the
transaction.Exec call deleting from UserAccessTokens) and add the IsActive
filter to the WHERE clause to mirror GetExpiredBefore, or alternatively add a
clear comment and/or separate method name indicating it intentionally deletes
inactive tokens as well.
- server.go: initialize s.Audit before initJobs() so the cleanup worker never captures a nil audit logger. - Replace DeleteExpired(cutoff) with DeleteByIds([]string) on UserAccessTokenStore. The worker now fetches a batch via GetExpiredBefore, emits one audit record per token, then deletes exactly that batch by id — guaranteeing 1:1 audit/delete pairing and eliminating the IsActive-filter mismatch between reads and deletes. The worker loops up to maxBatches (=1000) x batchLimit (=1000) rows per run and stops when GetExpiredBefore returns less than batchLimit or zero rows. - GetExpiredBefore now selects an explicit column set that omits the secret Token column, so the PAT secret never travels from DB to app. - DeleteByIds surfaces an error from RowsAffected instead of silently returning 0. - Remove dead job.Data initialization in the worker. - api4 test: set IsActive: true explicitly, walk the AppError chain and assert the specific Id app.user_access_token.expired so future 401 regressions are caught. Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
server/channels/store/storetest/user_access_token_store.go (1)
285-288: Harden cleanup to include the expired fixture too.If the test exits early before Line 317, the expired token can remain and pollute later runs. Add it to
t.Cleanupfor deterministic isolation.♻️ Proposed tweak
t.Cleanup(func() { _ = ss.UserAccessToken().Delete(nonExpiring.Id) + _ = ss.UserAccessToken().Delete(expired.Id) _ = ss.UserAccessToken().Delete(future.Id) })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/channels/store/storetest/user_access_token_store.go` around lines 285 - 288, The cleanup currently calls ss.UserAccessToken().Delete for nonExpiring and future but misses the expired fixture; update the t.Cleanup closure to also delete the expired token by calling ss.UserAccessToken().Delete(expired.Id) so all three fixtures (nonExpiring, future, expired) are removed and tests remain isolated even on early exit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/channels/store/sqlstore/user_access_token_store.go`:
- Around line 269-306: The function DeleteByIds currently uses unnamed returns
but passes &err into finalizeTransactionX, so rollback errors appended by the
defer can be lost; change the signature of DeleteByIds to use named returns
(e.g., deleted int64, err error), update all return statements to either set the
named variables or use bare returns so finalizeTransactionX can modify err via
&err, and keep the defer finalizeTransactionX(transaction, &err) call; make sure
any early returns set deleted (0) where appropriate and continue to reference
transaction, sessionsQuery, and res as before.
---
Nitpick comments:
In `@server/channels/store/storetest/user_access_token_store.go`:
- Around line 285-288: The cleanup currently calls ss.UserAccessToken().Delete
for nonExpiring and future but misses the expired fixture; update the t.Cleanup
closure to also delete the expired token by calling
ss.UserAccessToken().Delete(expired.Id) so all three fixtures (nonExpiring,
future, expired) are removed and tests remain isolated even on early exit.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: de7fefb2-2356-4695-bf8c-16719a12876c
📒 Files selected for processing (9)
server/channels/api4/user_test.goserver/channels/app/server.goserver/channels/jobs/cleanup_expired_access_tokens/worker.goserver/channels/store/retrylayer/retrylayer.goserver/channels/store/sqlstore/user_access_token_store.goserver/channels/store/store.goserver/channels/store/storetest/mocks/UserAccessTokenStore.goserver/channels/store/storetest/user_access_token_store.goserver/channels/store/timerlayer/timerlayer.go
🚧 Files skipped from review as they are similar to previous changes (2)
- server/channels/jobs/cleanup_expired_access_tokens/worker.go
- server/channels/store/timerlayer/timerlayer.go
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
- DeleteByIds now declares (deleted int64, err error) and uses bare returns on every error path so finalizeTransactionX can append a rollback failure to the returned error via merror.Append. Previously early returns short-circuited the deferred rollback's error contribution. - Add the expired token to the test cleanup so all three fixtures are removed even on early test exit. Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
server/channels/store/storetest/user_access_token_store.go (1)
307-315: Strengthen secret-leak assertion across all returned rows.Currently,
Tokenemptiness is asserted only for the matched expired token. As a tighter regression check, assertrow.Tokenis empty for every row returned byGetExpiredBefore.✅ Suggested test tightening
for _, row := range expiredRows { + require.Empty(t, row.Token, "expired token secret should not be returned") if row.Id == expired.Id { - require.Empty(t, row.Token, "expired token secret should not be returned") require.Equal(t, expired.ExpiresAt, row.ExpiresAt) found = true } require.NotEqual(t, nonExpiring.Id, row.Id, "non-expiring token must not be returned") require.NotEqual(t, future.Id, row.Id, "future token must not be returned") }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/channels/store/storetest/user_access_token_store.go` around lines 307 - 315, The test currently only asserts row.Token is empty for the matched expired token; update the loop over expiredRows (the results of GetExpiredBefore) to assert that every row returned has an empty Token field (require.Empty(t, row.Token)) while still keeping the existing checks that the matched expired row has the expected ExpiresAt and that nonExpiring.Id and future.Id are not present; i.e., inside the for _, row := range expiredRows loop, add a require.Empty(t, row.Token) at the top to enforce no secret leaks for any returned row.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/channels/store/sqlstore/user_access_token_store.go`:
- Around line 242-255: In GetExpiredBefore, validate the signed int limit before
casting to uint64 to avoid negative values becoming huge unsigned numbers; add a
guard (e.g., if limit <= 0 { limit = 0 } or clamp to a safe max) before the call
to query.Limit(uint64(limit)), and use the validated/clamped variable in the
Limit call so negative inputs cannot widen the query unexpectedly.
---
Nitpick comments:
In `@server/channels/store/storetest/user_access_token_store.go`:
- Around line 307-315: The test currently only asserts row.Token is empty for
the matched expired token; update the loop over expiredRows (the results of
GetExpiredBefore) to assert that every row returned has an empty Token field
(require.Empty(t, row.Token)) while still keeping the existing checks that the
matched expired row has the expected ExpiresAt and that nonExpiring.Id and
future.Id are not present; i.e., inside the for _, row := range expiredRows
loop, add a require.Empty(t, row.Token) at the top to enforce no secret leaks
for any returned row.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: edec3e26-b3f9-4b43-b863-3131da7f8c2a
📒 Files selected for processing (2)
server/channels/store/sqlstore/user_access_token_store.goserver/channels/store/storetest/user_access_token_store.go
There was a problem hiding this comment.
Validate limit before uint64 cast in GetExpiredBefore.
Line 255 casts limit to uint64; a negative limit becomes a huge value and can unintentionally widen the query.
🔧 Proposed fix
func (s SqlUserAccessTokenStore) GetExpiredBefore(cutoff int64, limit int) ([]*model.UserAccessToken, error) {
tokens := []*model.UserAccessToken{}
+ if limit <= 0 {
+ return tokens, nil
+ }
query := s.getQueryBuilder().
Select(
"UserAccessTokens.Id",
"UserAccessTokens.UserId",
@@
Where(sq.Gt{"UserAccessTokens.ExpiresAt": 0}).
Where(sq.LtOrEq{"UserAccessTokens.ExpiresAt": cutoff}).
Where(sq.Eq{"UserAccessTokens.IsActive": true}).
OrderBy("UserAccessTokens.ExpiresAt ASC").
Limit(uint64(limit))🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/channels/store/sqlstore/user_access_token_store.go` around lines 242 -
255, In GetExpiredBefore, validate the signed int limit before casting to uint64
to avoid negative values becoming huge unsigned numbers; add a guard (e.g., if
limit <= 0 { limit = 0 } or clamp to a safe max) before the call to
query.Limit(uint64(limit)), and use the validated/clamped variable in the Limit
call so negative inputs cannot widen the query unexpectedly.

Summary
Implements the backend piece of MM-68419 (PAT Security Enhancements: Expiry Enforcement & Token Lifecycle Management): adds a persisted expiry to personal access tokens and enforces it end-to-end.
UserAccessTokenmodel gains anExpiresAtfield (Unix millis,0== never expires) and a newIsExpired()helper.IsValid()rejects negative values.000170_add_expiresat_to_user_access_tokensaddsExpiresAt bigint NOT NULL DEFAULT 0touseraccesstokens. Existing rows inherit the default, so all pre-feature tokens continue to work as "non-expiring" until an admin updates them.SqlUserAccessTokenStore.Saveand every select query now includeExpiresAt. Two new store methods support the background job:GetExpiredBefore(cutoff, limit)(strips the secretTokenvalue from its returned rows) andDeleteExpired(cutoff)(single-transactionDELETEon matchingSessions+UserAccessTokens, returns the number of deleted rows). Thestore.UserAccessTokenStoreinterface, retry/timer layers, and mocks are updated accordingly.App.createSessionForUserAccessToken, expired tokens are rejected with a new i18n errorapp.user_access_token.expired(HTTP 401) and an audit record (rejectExpiredUserAccessToken) is emitted. When a PAT'sExpiresAt > 0, the minted session'sExpiresAtis clamped to the token's expiry so long-lived cached sessions also honor it.cleanup_expired_access_tokens(periodic, 1 hour, always enabled) deletes expired PATs and their sessions, emitting oneexpireUserAccessTokenaudit record per deleted token. Registered inServer.initJobs.The scope is limited to what the ticket asks for and intentionally does not add any user-facing API surface for setting
ExpiresAt(expiry can already be set via the model in automation/tests; a UI/API follow-up can be layered on top in a separate ticket from the epic).Ticket Link
Jira: https://mattermost.atlassian.net/browse/MM-68419
Release Note
Change Impact: 🟡 Medium
Regression Risk: Changes affect authentication/session creation, persistence (DB migration), and store layers (interfaces, retry/timer/mocks), creating moderate regression risk if any layer mismatches or session-cache interactions are missed; existing tokens default to non-expiring (0) which reduces immediate breakage.
QA Recommendation: Perform focused manual QA on end-to-end PAT flows: creating sessions with expiring vs non-expiring tokens, session expiry clamping, background cleanup job behavior under concurrency, and audit event emission; rely on existing unit tests for store-layer coverage.
Generated by CodeRabbitAI
Release Notes
ExpiresAtcolumn toUserAccessTokenstable; existing tokens default to 0 (never expires)ExpiresAtset are now validated at session creation; expired tokens are rejected with HTTP 401cleanup_expired_access_tokensrunning hourly to delete expired PATs and associated sessionsExpiresAtexpireUserAccessToken(background cleanup) andrejectExpiredUserAccessToken(validation failure)