MM-68419: Add expires_at to PAT data model and enforce expiry at token validation by hanzei · Pull Request #36243 · mattermost/mattermost · GitHub
Skip to content

MM-68419: Add expires_at to PAT data model and enforce expiry at token validation#36243

Open
hanzei wants to merge 6 commits intomasterfrom
cursor/pat-expires-at-0af3
Open

MM-68419: Add expires_at to PAT data model and enforce expiry at token validation#36243
hanzei wants to merge 6 commits intomasterfrom
cursor/pat-expires-at-0af3

Conversation

@hanzei
Copy link
Copy Markdown
Contributor

@hanzei hanzei commented Apr 23, 2026

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.

  • UserAccessToken model gains an ExpiresAt field (Unix millis, 0 == never expires) and a new IsExpired() helper. IsValid() rejects negative values.
  • New migration 000170_add_expiresat_to_user_access_tokens adds ExpiresAt bigint NOT NULL DEFAULT 0 to useraccesstokens. Existing rows inherit the default, so all pre-feature tokens continue to work as "non-expiring" until an admin updates them.
  • SqlUserAccessTokenStore.Save and every select query now include ExpiresAt. Two new store methods support the background job: GetExpiredBefore(cutoff, limit) (strips the secret Token value from its returned rows) and DeleteExpired(cutoff) (single-transaction DELETE on matching Sessions + UserAccessTokens, returns the number of deleted rows). The store.UserAccessTokenStore interface, retry/timer layers, and mocks are updated accordingly.
  • In App.createSessionForUserAccessToken, expired tokens are rejected with a new i18n error app.user_access_token.expired (HTTP 401) and an audit record (rejectExpiredUserAccessToken) is emitted. When a PAT's ExpiresAt > 0, the minted session's ExpiresAt is clamped to the token's expiry so long-lived cached sessions also honor it.
  • New background job cleanup_expired_access_tokens (periodic, 1 hour, always enabled) deletes expired PATs and their sessions, emitting one expireUserAccessToken audit record per deleted token. Registered in Server.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

Added an ExpiresAt column to the UserAccessTokens table and enforced expiry at token validation time. Expired personal access tokens are now rejected with HTTP 401 and reaped hourly by a new background job (cleanup_expired_access_tokens); audit events are emitted on rejection (rejectExpiredUserAccessToken) and reap (expireUserAccessToken). Tokens with ExpiresAt = 0 remain non-expiring, preserving behavior for all pre-existing tokens.
Open in Web Open in Cursor 

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

  • Added ExpiresAt column to UserAccessTokens table; existing tokens default to 0 (never expires)
  • Personal access tokens with ExpiresAt set are now validated at session creation; expired tokens are rejected with HTTP 401
  • Added background job cleanup_expired_access_tokens running hourly to delete expired PATs and associated sessions
  • Session lifetime is clamped to token expiry when token has non-zero ExpiresAt
  • Audit events: expireUserAccessToken (background cleanup) and rejectExpiredUserAccessToken (validation failure)

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>
@mm-cloud-bot mm-cloud-bot added the release-note Denotes a PR that will be considered when it comes time to generate release notes. label Apr 23, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 23, 2026

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 23, 2026

⚠️ One or more flaky tests detected ⚠️

TestRetries
Postgres (shard 0) (Results)
com/mattermost/mattermost/server/v8/channels/store/sqlstore.TestThreadStore/postgres/ThreadStorePopulation/Get_unread_reply_counts_for_thread1
com/mattermost/mattermost/server/v8/channels/store/sqlstore.TestThreadStore/postgres/ThreadStorePopulation1
com/mattermost/mattermost/server/v8/channels/store/sqlstore.TestThreadStore/postgres1
com/mattermost/mattermost/server/v8/channels/store/sqlstore.TestThreadStore1
com/mattermost/mattermost/server/v8/channels/api4.TestCreateUserAccessToken/expired_token_is_rejected_at_validation3
com/mattermost/mattermost/server/v8/channels/api4.TestCreateUserAccessToken3

cursoragent and others added 2 commits April 23, 2026 17:25
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>
@hanzei hanzei marked this pull request as ready for review April 23, 2026 17:47
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 23, 2026

Documentation Impact Analysis — updates needed

Documentation Impact Analysis

Overall Assessment: Documentation Updates Required

Changes Summary

This 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

Change Type Files Changed Affected Personas Documentation Action Docs Location
New API Response Field server/public/model/user_access_token.go Developer/Integrator Document new ExpiresAt field in PAT responses API docs handled via OpenAPI auto-publish
Authentication Behavior Change server/channels/app/session.go End User, Developer/Integrator Document expired token rejection behavior and error messages docs/source/integrations-guide/incoming-webhooks.rst, docs/source/integrations-guide/outgoing-webhooks.rst
New Background Job server/channels/jobs/cleanup_expired_access_tokens/ System Administrator Document new cleanup job behavior and scheduling docs/source/administration-guide/configure/integrations-configuration-settings.rst
Database Schema Change server/channels/db/migrations/postgres/000170_add_expiresat_to_user_access_tokens.up.sql System Administrator Update upgrade notes for schema migration docs/source/administration-guide/upgrade/important-upgrade-notes.rst
New Audit Events server/public/model/audit_events.go Security/Compliance Officer Document new audit event types docs/source/administration-guide/comply/embedded-json-audit-log-schema.rst
New Error Message server/i18n/en.json End User, Developer/Integrator Document new error responses for expired tokens New section needed in existing integration guides

Recommended Actions

  • Update docs/source/administration-guide/configure/integrations-configuration-settings.rst to document the new background cleanup job and token expiry behavior
  • Update docs/source/administration-guide/comply/embedded-json-audit-log-schema.rst to add the new audit events: "expireUserAccessToken" and "rejectExpiredUserAccessToken"
  • Update docs/source/administration-guide/upgrade/important-upgrade-notes.rst to mention the new database column for personal access tokens
  • Update docs/source/integrations-guide/incoming-webhooks.rst and docs/source/integrations-guide/outgoing-webhooks.rst to document that PATs can now expire and how to handle expired token errors
  • Consider adding a new section to docs/source/administration-guide/manage/mmctl-command-line-tool.rst documenting token expiry management (if mmctl commands are extended to support expiry)

Confidence

High — 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.

@github-actions github-actions Bot added the Docs/Needed Requires documentation label Apr 23, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Migrations
server/channels/db/migrations/postgres/000170_add_expiresat_to_user_access_tokens.up.sql, server/channels/db/migrations/postgres/000170_add_expiresat_to_user_access_tokens.down.sql
Add expiresat bigint column (default 0) and provide down migration to drop it.
Model & Validation
server/public/model/user_access_token.go, server/public/model/user_access_token_test.go
Add ExpiresAt field, IsExpired() method, and validation/tests for non-negative and expiry semantics.
API & Session Logic
server/channels/app/session.go, server/channels/api4/user_test.go
Reject expired PATs during session creation with audit event; align session ExpiresAt to token expiry; add API test covering expired-token rejection.
Cleanup Job & Scheduling
server/channels/jobs/cleanup_expired_access_tokens/worker.go, server/channels/jobs/cleanup_expired_access_tokens/scheduler.go, server/public/model/job.go
新增 hourly scheduler and worker for cleanup_expired_access_tokens; worker batches expired-token reads, deletes tokens+sessions, logs per-token audit events; job type constant added.
Server init / Audit
server/channels/app/server.go
Register new job worker/scheduler and ensure s.Audit is initialized before job registration; always call audit configuration.
Store API & SQL Impl
server/channels/store/store.go, server/channels/store/sqlstore/user_access_token_store.go
Add GetExpiredBefore(cutoff, limit) and DeleteByIds(tokenIDs) to store interface and SQL implementation (replica read for expired tokens; transactional delete of sessions and tokens).
Store Layers
server/channels/store/retrylayer/retrylayer.go, server/channels/store/timerlayer/timerlayer.go
Add retry logic (up to 3 attempts) and timing/metrics wrappers for new store methods.
Tests & Mocks
server/channels/store/storetest/mocks/UserAccessTokenStore.go, server/channels/store/storetest/user_access_token_store.go
Extend mocks and add expiry-focused tests validating retrieval (omitting secret), GetExpiredBefore semantics, and DeleteByIds behavior including session cleanup and no-ops.
i18n & Audit Events
server/i18n/en.json, server/public/model/audit_events.go
Add i18n keys for expired-token messages and audit event constants for token expiry and rejection.

Sequence Diagram

sequenceDiagram
    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)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

2: Dev Review, 3: QA Review

Suggested reviewers

  • isacikgoz
  • agarciamontoro
  • nickmisasi
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding expires_at field to the PAT data model and enforcing expiry validation. It directly summarizes the core objectives of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/pat-expires-at-0af3

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 @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Token column:

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 for RowsAffected.

If RowsAffected() fails, the error is silently ignored and deleted defaults 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: Unused job.Data initialization.

job.Data is 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

📥 Commits

Reviewing files that changed from the base of the PR and between 9d33d87 and f175631.

📒 Files selected for processing (18)
  • server/channels/api4/user_test.go
  • server/channels/app/server.go
  • server/channels/app/session.go
  • server/channels/db/migrations/postgres/000170_add_expiresat_to_user_access_tokens.down.sql
  • server/channels/db/migrations/postgres/000170_add_expiresat_to_user_access_tokens.up.sql
  • server/channels/jobs/cleanup_expired_access_tokens/scheduler.go
  • server/channels/jobs/cleanup_expired_access_tokens/worker.go
  • server/channels/store/retrylayer/retrylayer.go
  • server/channels/store/sqlstore/user_access_token_store.go
  • server/channels/store/store.go
  • server/channels/store/storetest/mocks/UserAccessTokenStore.go
  • server/channels/store/storetest/user_access_token_store.go
  • server/channels/store/timerlayer/timerlayer.go
  • server/i18n/en.json
  • server/public/model/audit_events.go
  • server/public/model/job.go
  • server/public/model/user_access_token.go
  • server/public/model/user_access_token_test.go

Comment thread server/channels/api4/user_test.go Outdated
Comment thread server/channels/app/server.go
Comment thread server/channels/jobs/cleanup_expired_access_tokens/worker.go Outdated
Comment on lines +274 to +277
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")
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.Cleanup for 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

📥 Commits

Reviewing files that changed from the base of the PR and between f175631 and d5409bb.

📒 Files selected for processing (9)
  • server/channels/api4/user_test.go
  • server/channels/app/server.go
  • server/channels/jobs/cleanup_expired_access_tokens/worker.go
  • server/channels/store/retrylayer/retrylayer.go
  • server/channels/store/sqlstore/user_access_token_store.go
  • server/channels/store/store.go
  • server/channels/store/storetest/mocks/UserAccessTokenStore.go
  • server/channels/store/storetest/user_access_token_store.go
  • server/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

Comment thread server/channels/store/sqlstore/user_access_token_store.go Outdated
@hanzei
Copy link
Copy Markdown
Contributor Author

hanzei commented Apr 23, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

- 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>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, Token emptiness is asserted only for the matched expired token. As a tighter regression check, assert row.Token is empty for every row returned by GetExpiredBefore.

✅ 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

📥 Commits

Reviewing files that changed from the base of the PR and between d5409bb and 51976fe.

📒 Files selected for processing (2)
  • server/channels/store/sqlstore/user_access_token_store.go
  • server/channels/store/storetest/user_access_token_store.go

Comment on lines +242 to +255
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Docs/Needed Requires documentation release-note Denotes a PR that will be considered when it comes time to generate release notes.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants