mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 10:14:32 +00:00
feat(pat): regenerate a token's secret and reset its expiry
POST /api/user/tokens/<id>/regenerate swaps the secret of an existing token in place: name, scopes and restrictions stay, the old secret stops matching at once, and the expiry is reset. The lifetime defaults to the one the token was last issued with (clamped to today's policy) or to expires_in_days when given. An expired token can be renewed this way; a revoked one cannot. It is session only like the rest of token management, and writes a pat_regenerated audit event. regenerated_at records the rotation.
This commit is contained in:
1 parent
3e38dc19ec
commit
91f2ec2f09
10 files changed
+275
-4
No files matched your search
@@ -1098,6 +1098,7 @@ personal_access_tokens_table = Table(
|
||||
Column("last_used_at", DateTime(timezone=True)),
|
||||
Column("last_used_ip", Text),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("regenerated_at", DateTime(timezone=True)),
|
||||
Column("revoked_at", DateTime(timezone=True)),
|
||||
Column("revoke_reason", Text),
|
||||
CheckConstraint("status IN ('active', 'revoked')", name="personal_access_tokens_status_check"),
|
||||
|
||||
@@ -14,7 +14,7 @@ from docsgpt.storage.db.base_repository import row_to_dict
|
||||
# token_hash never leaves the repository except through find_active_by_hash.
|
||||
_PUBLIC_COLUMNS = (
|
||||
"id, user_id, name, token_prefix, scopes, resource_filter, status, "
|
||||
"expires_at, last_used_at, last_used_ip, created_at, revoked_at, revoke_reason"
|
||||
"expires_at, last_used_at, last_used_ip, created_at, regenerated_at, revoked_at, revoke_reason"
|
||||
)
|
||||
|
||||
|
||||
@@ -155,6 +155,41 @@ class PersonalAccessTokensRepository:
|
||||
{"id": token_id, "ip": ip, "min_interval": min_interval_seconds},
|
||||
)
|
||||
|
||||
def regenerate(
|
||||
self,
|
||||
token_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
expires_at: Optional[datetime],
|
||||
) -> Optional[dict]:
|
||||
"""Swap a live token's secret and expiry in place. The old secret stops matching at once.
|
||||
|
||||
Expired tokens qualify (renewal is the point); revoked ones do not.
|
||||
Usage fields are cleared because they described the old secret.
|
||||
"""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
f"""
|
||||
UPDATE personal_access_tokens
|
||||
SET token_hash = :token_hash, token_prefix = :token_prefix,
|
||||
expires_at = :expires_at, regenerated_at = now(),
|
||||
last_used_at = NULL, last_used_ip = NULL
|
||||
WHERE id = CAST(:id AS uuid) AND user_id = :user_id AND status = 'active'
|
||||
RETURNING {_PUBLIC_COLUMNS}
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": token_id,
|
||||
"user_id": user_id,
|
||||
"token_hash": token_hash,
|
||||
"token_prefix": token_prefix,
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def revoke(self, token_id: str, user_id: Optional[str] = None, *, reason: str = "user_revoked") -> bool:
|
||||
"""Revoke one token. ``user_id=None`` is the admin path (any owner)."""
|
||||
sql = (
|
||||
|
||||
Reference in new issue
Block a user