Files
multica/server/pkg/db/generated/personal_access_token.sql.go
Bohan Jiang 4864831721 MUL-2744: feat(auth): auto-renew daemon PAT in-place within 7-day window (#3360)
* MUL-2744: feat(auth): auto-renew daemon PAT in-place within 7-day window

Daemons currently hold a 90-day PAT and have no renewal path: once the
token's expires_at passes, every request 401s and the user has to find
the silent failure in the daemon log and re-run `multica login`.

This adds an in-place renewal:

- New `POST /api/tokens/current/renew` (Auth-protected, mul_ only). The
  server checks remaining lifetime: ≥ 7 days is a no-op; < 7 days bumps
  expires_at to now + 90 days via a guarded UPDATE that makes concurrent
  renews idempotent (the WHERE expires_at < $2 clause means only one
  writer wins; the loser sees pgx.ErrNoRows and reports the already-
  extended value). No raw token rotation — the same secret stays in
  every CLI/daemon process sharing the config.

- Daemon-side `tokenRenewalLoop`: fires once on startup (covers
  machine-was-off cases) and then every 3 days. With a 7-day server
  threshold this gives at least two renewal attempts before the window
  closes, so a single network blip can't push the token out.

- 401 fallback: when the renew call comes back 401 (token already
  revoked/expired), the daemon logs a user-actionable WARN telling the
  operator to run `multica login` — instead of the current silent
  failure mode. Loop keeps running so the warning repeats until fixed.

PAT cache (auth.AuthCacheTTL = 10m) doesn't need invalidation: the next
miss after the UPDATE re-reads the row and re-caches with the bumped
TTL automatically.

Co-authored-by: multica-agent <github@multica.ai>

* MUL-2744: fix(auth): renew PAT before first sync; CAS against renewal threshold

Addresses the two issues Elon raised on #3360.

Must-fix: if the PAT is already revoked/expired when the daemon starts,
syncWorkspacesFromAPI 401s and Run returns before the background
tokenRenewalLoop ever fires its initial renewal. The operator only sees
a generic auth failure in the workspace-sync log with no hint that
'multica login' is the fix. Now the startup path runs an inline
tryRenewToken first, surfacing the existing 401 WARN before anything
else gets a chance to fail. Pulled the renew + first-sync pair into
preflightAuth so the ordering invariant is enforced at one site and
tests can exercise the failure modes without spinning up the full Run
setup. Removed the redundant initial tryRenewToken from
tokenRenewalLoop — startup now owns the first call.

Nit: the previous WHERE clause on ExtendPersonalAccessTokenExpiry
(expires_at < $2) did not actually make concurrent renews idempotent
the way the comment claimed. Two callers race-computing
$2 = now + 90d produce strictly-different values, and the second
writer's $2 always exceeds the row the first writer just wrote, so the
UPDATE re-matches and bumps again. Switched to a CAS against the
renewal threshold (expires_at <= $renew_threshold_at, i.e. now + 7d):
once writer A pushes expires_at past the threshold, writer B's UPDATE
matches zero rows and the loser falls back to reporting the
already-extended value as a no-op.

Tests:
- TestPreflightAuth_RenewsBeforeWorkspaceSyncOnExpiredToken locks in
  the call ordering — renew endpoint is hit before workspaces, and the
  re-login WARN appears even though both endpoints 401.
- TestPreflightAuth_SyncProceedsWhenRenewIsNoOp covers steady-state
  startup: a renew=false no-op must still progress to workspace sync.
- TestPreflightAuth_TransientRenewFailureDoesNotBlockStartup covers a
  500 from the renew endpoint — startup must continue, no WARN.
- TestRenewPAT_ParallelRenewExtendsExactlyOnce fires N=8 concurrent
  renews at one row and asserts exactly one returns renewed=true with
  the others reporting the same already-extended expires_at, plus the
  DB carries only that single bumped value.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 22:22:26 +08:00

176 lines
5.2 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: personal_access_token.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createPersonalAccessToken = `-- name: CreatePersonalAccessToken :one
INSERT INTO personal_access_token (user_id, name, token_hash, token_prefix, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at
`
type CreatePersonalAccessTokenParams struct {
UserID pgtype.UUID `json:"user_id"`
Name string `json:"name"`
TokenHash string `json:"token_hash"`
TokenPrefix string `json:"token_prefix"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
}
func (q *Queries) CreatePersonalAccessToken(ctx context.Context, arg CreatePersonalAccessTokenParams) (PersonalAccessToken, error) {
row := q.db.QueryRow(ctx, createPersonalAccessToken,
arg.UserID,
arg.Name,
arg.TokenHash,
arg.TokenPrefix,
arg.ExpiresAt,
)
var i PersonalAccessToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.TokenHash,
&i.TokenPrefix,
&i.ExpiresAt,
&i.LastUsedAt,
&i.Revoked,
&i.CreatedAt,
)
return i, err
}
const extendPersonalAccessTokenExpiry = `-- name: ExtendPersonalAccessTokenExpiry :one
UPDATE personal_access_token
SET expires_at = $1
WHERE id = $2
AND revoked = FALSE
AND expires_at IS NOT NULL
AND expires_at > now()
AND expires_at <= $3
RETURNING expires_at
`
type ExtendPersonalAccessTokenExpiryParams struct {
NewExpiresAt pgtype.Timestamptz `json:"new_expires_at"`
ID pgtype.UUID `json:"id"`
RenewThresholdAt pgtype.Timestamptz `json:"renew_threshold_at"`
}
// In-place renew: only bumps expires_at when the token is still valid
// (not revoked, not already expired) AND the existing expires_at is
// still inside the renewal threshold ($3, e.g. now + 7d). Phrasing the
// CAS this way — "is the row still renewable?" rather than "is the
// requested new expiry larger than the current one?" — makes concurrent
// renews idempotent: once writer A bumps expires_at past the threshold,
// writer B's UPDATE matches zero rows (sqlc :one returns pgx.ErrNoRows,
// which the caller treats as "already renewed"). A naive `expires_at <
// $2` would still match because two callers race-computing
// `$2 = now + 90d` produce strictly-different values and the second
// one's $2 is always greater than the row A just wrote.
func (q *Queries) ExtendPersonalAccessTokenExpiry(ctx context.Context, arg ExtendPersonalAccessTokenExpiryParams) (pgtype.Timestamptz, error) {
row := q.db.QueryRow(ctx, extendPersonalAccessTokenExpiry, arg.NewExpiresAt, arg.ID, arg.RenewThresholdAt)
var expires_at pgtype.Timestamptz
err := row.Scan(&expires_at)
return expires_at, err
}
const getPersonalAccessTokenByHash = `-- name: GetPersonalAccessTokenByHash :one
SELECT id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at FROM personal_access_token
WHERE token_hash = $1
AND revoked = FALSE
AND (expires_at IS NULL OR expires_at > now())
`
func (q *Queries) GetPersonalAccessTokenByHash(ctx context.Context, tokenHash string) (PersonalAccessToken, error) {
row := q.db.QueryRow(ctx, getPersonalAccessTokenByHash, tokenHash)
var i PersonalAccessToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.TokenHash,
&i.TokenPrefix,
&i.ExpiresAt,
&i.LastUsedAt,
&i.Revoked,
&i.CreatedAt,
)
return i, err
}
const listPersonalAccessTokensByUser = `-- name: ListPersonalAccessTokensByUser :many
SELECT id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at FROM personal_access_token
WHERE user_id = $1
AND revoked = FALSE
ORDER BY created_at DESC
`
func (q *Queries) ListPersonalAccessTokensByUser(ctx context.Context, userID pgtype.UUID) ([]PersonalAccessToken, error) {
rows, err := q.db.Query(ctx, listPersonalAccessTokensByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PersonalAccessToken{}
for rows.Next() {
var i PersonalAccessToken
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.TokenHash,
&i.TokenPrefix,
&i.ExpiresAt,
&i.LastUsedAt,
&i.Revoked,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const revokePersonalAccessToken = `-- name: RevokePersonalAccessToken :one
UPDATE personal_access_token
SET revoked = TRUE
WHERE id = $1 AND user_id = $2
RETURNING token_hash
`
type RevokePersonalAccessTokenParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) RevokePersonalAccessToken(ctx context.Context, arg RevokePersonalAccessTokenParams) (string, error) {
row := q.db.QueryRow(ctx, revokePersonalAccessToken, arg.ID, arg.UserID)
var token_hash string
err := row.Scan(&token_hash)
return token_hash, err
}
const updatePersonalAccessTokenLastUsed = `-- name: UpdatePersonalAccessTokenLastUsed :exec
UPDATE personal_access_token
SET last_used_at = now()
WHERE id = $1
`
func (q *Queries) UpdatePersonalAccessTokenLastUsed(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, updatePersonalAccessTokenLastUsed, id)
return err
}