mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
* 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>
176 lines
5.2 KiB
Go
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
|
|
}
|