mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 22:14:05 +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
@@ -20,7 +20,7 @@ Every token is limited in three ways:
|
||||
3. Optionally restrict it to specific resources and pick an expiry.
|
||||
4. Copy the token. It starts with `dgpt_pat_` and is shown **once**. DocsGPT stores only a hash of it, so a lost token cannot be recovered. Revoke it and create a new one.
|
||||
|
||||
Tokens can only be created and revoked from a signed-in session. A token cannot create, list or revoke tokens, so a leaked token cannot mint a replacement for itself.
|
||||
Tokens can only be created, regenerated and revoked from a signed-in session. A token cannot create, list, regenerate or revoke tokens, so a leaked token cannot mint a replacement for itself.
|
||||
|
||||
## Using a token
|
||||
|
||||
@@ -159,11 +159,12 @@ Restrictions and `agents apply`: a token restricted to specific agents can apply
|
||||
|
||||
- A token created without an explicit lifetime expires after `PAT_DEFAULT_LIFETIME_DAYS` (90 by default). Users can choose any lifetime up to `PAT_MAX_LIFETIME_DAYS` (365 by default).
|
||||
- Non-expiring tokens are available only when the operator sets `PAT_ALLOW_NON_EXPIRING=true`.
|
||||
- **Regenerate** in **Settings → Access Tokens** issues a new secret for the same token and resets its expiry. The name, scopes and restrictions stay; the old secret stops working immediately, so update whatever uses it. The new lifetime defaults to the one the token was last issued with, and an expired token can be renewed this way (a revoked one cannot). This is the way to rotate a secret or extend a token without rebuilding its scopes.
|
||||
- Revoking a token in **Settings → Access Tokens** takes effect on the next request.
|
||||
- Admins can list a user's tokens with `GET /api/admin/users/<user_id>/tokens` and revoke any token with `DELETE /api/admin/tokens/<token_id>`. The admin **revoke sessions** action also revokes all of that user's tokens.
|
||||
- Tokens of a deactivated user (through the admin API or SCIM) stop working immediately and work again if the user is reactivated.
|
||||
- `GET /api/user/tokens` reports a token past its expiry as `"status": "expired"`.
|
||||
- Token creation and revocation are recorded in the authentication audit log (`pat_created`, `pat_revoked`), visible to admins.
|
||||
- Token creation and revocation are recorded in the authentication audit log (`pat_created`, `pat_regenerated`, `pat_revoked`), visible to admins.
|
||||
|
||||
An expired token's name can be reused: creating a token with that name retires the expired one.
|
||||
|
||||
@@ -191,6 +192,7 @@ These endpoints need a signed-in session and cannot be called with a token.
|
||||
| --- | --- |
|
||||
| `GET /api/user/tokens` | List your tokens, the scope catalog and the server's token policy |
|
||||
| `POST /api/user/tokens` | Create a token. Body: `name`, `scopes`, optional `resource_filter`, optional `expires_in_days` (`0` = never, when allowed). The response carries the plaintext `token` once |
|
||||
| `POST /api/user/tokens/<id>/regenerate` | New secret and new expiry for the same token. Optional body `expires_in_days`; omitted = the lifetime it was last issued with. The response carries the plaintext `token` once |
|
||||
| `DELETE /api/user/tokens/<id>` | Revoke a token |
|
||||
|
||||
## Good practice
|
||||
|
||||
@@ -10,7 +10,8 @@ user can tell their tokens apart in the UI.
|
||||
itself). ``resource_filter`` optionally narrows a resource family to specific
|
||||
ids, e.g. ``{"agents": ["<uuid>"]}``; an absent family is unrestricted within
|
||||
the token's scopes. ``expires_at`` is NULL only when the operator allows
|
||||
non-expiring tokens.
|
||||
non-expiring tokens. Regenerating a token swaps its secret in place and stamps
|
||||
``regenerated_at``; the row, its name, scopes and restrictions stay.
|
||||
|
||||
``user_id`` is the auth ``sub``; no FK or trigger, mirroring ``devices`` and
|
||||
``user_roles`` so a token row never blocks user deletion.
|
||||
@@ -47,6 +48,7 @@ def upgrade() -> None:
|
||||
last_used_at TIMESTAMPTZ,
|
||||
last_used_ip TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
regenerated_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoke_reason TEXT
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ from docsgpt.api.pat.tokens import (
|
||||
is_pat,
|
||||
normalize_resource_filter,
|
||||
normalize_scopes,
|
||||
renewal_lifetime_days,
|
||||
resolve_expiry,
|
||||
)
|
||||
from docsgpt.api.user.authz import admin_required
|
||||
@@ -84,6 +85,7 @@ def serialize_token(row: dict) -> dict:
|
||||
"last_used_at": row.get("last_used_at"),
|
||||
"last_used_ip": row.get("last_used_ip"),
|
||||
"created_at": row.get("created_at"),
|
||||
"regenerated_at": row.get("regenerated_at"),
|
||||
"revoked_at": row.get("revoked_at"),
|
||||
}
|
||||
|
||||
@@ -214,6 +216,66 @@ class PersonalAccessToken(Resource):
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
|
||||
|
||||
@pat_ns.route("/user/tokens/<string:token_id>/regenerate")
|
||||
class PersonalAccessTokenRegenerate(Resource):
|
||||
def post(self, token_id):
|
||||
"""Issue a new secret for a token and reset its expiry.
|
||||
|
||||
Name, scopes and restrictions stay; the old secret stops working at
|
||||
once. ``expires_in_days`` is optional and defaults to the lifetime the
|
||||
token was last issued with. An expired token can be renewed this way; a
|
||||
revoked one cannot. The plaintext ``token`` is returned here and never again.
|
||||
"""
|
||||
user_id = _session_user_id()
|
||||
if not user_id:
|
||||
return _error("Authentication required", 401)
|
||||
if not auth_type_supports_pats():
|
||||
return _error("Personal access tokens are not available on this server", 403)
|
||||
if not _valid_uuid(token_id):
|
||||
return _error("Token not found", 404)
|
||||
body = request.get_json(silent=True)
|
||||
if body is None:
|
||||
body = {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
|
||||
token, token_hash, token_prefix = generate_token()
|
||||
with db_session() as conn:
|
||||
repo = PersonalAccessTokensRepository(conn)
|
||||
current = repo.get(token_id, user_id)
|
||||
if not current or current["status"] != "active":
|
||||
return _error("Token not found", 404)
|
||||
requested = body.get("expires_in_days")
|
||||
if requested is None:
|
||||
requested = renewal_lifetime_days(current)
|
||||
try:
|
||||
expires_at = resolve_expiry(requested)
|
||||
except ValueError as exc:
|
||||
return _error(str(exc), 400)
|
||||
row = repo.regenerate(
|
||||
token_id, user_id, token_hash=token_hash, token_prefix=token_prefix, expires_at=expires_at
|
||||
)
|
||||
if not row:
|
||||
# Revoked between the read and the write.
|
||||
return _error("Token not found", 404)
|
||||
AuthEventsRepository(conn).insert(
|
||||
user_id,
|
||||
"pat_regenerated",
|
||||
ip=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
metadata={
|
||||
"token_id": token_id,
|
||||
"name": row["name"],
|
||||
"expires_at": row.get("expires_at"),
|
||||
"previous_expires_at": current.get("expires_at"),
|
||||
},
|
||||
)
|
||||
return make_response(
|
||||
jsonify({"success": True, "token": token, "personal_access_token": serialize_token(row)}),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@pat_ns.route("/admin/users/<string:user_id>/tokens")
|
||||
class AdminUserTokens(Resource):
|
||||
@admin_required
|
||||
|
||||
@@ -321,6 +321,7 @@ DENIED: dict[str, tuple[str, ...]] = {
|
||||
"/": ("*",),
|
||||
"/api/user/tokens": ("*",),
|
||||
"/api/user/tokens/<string:token_id>": ("*",),
|
||||
"/api/user/tokens/<string:token_id>/regenerate": ("*",),
|
||||
"/api/generate_token": ("*",),
|
||||
"/api/combine": ("*",),
|
||||
"/api/download": ("*",),
|
||||
|
||||
@@ -169,6 +169,33 @@ def resolve_expiry(expires_in_days: Any) -> Optional[datetime]:
|
||||
return datetime.now(timezone.utc) + timedelta(days=days)
|
||||
|
||||
|
||||
def _parse_moment(value: Any) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
moment = value if isinstance(value, datetime) else datetime.fromisoformat(str(value))
|
||||
except ValueError:
|
||||
return None
|
||||
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def renewal_lifetime_days(row: dict) -> Optional[int]:
|
||||
"""The lifetime a token was last issued with, for renewing it on the same terms.
|
||||
|
||||
``0`` for a non-expiring token, ``None`` when it cannot be derived (the
|
||||
caller then falls back to the default). The result is clamped to today's
|
||||
maximum, since the policy may have tightened since the token was issued.
|
||||
"""
|
||||
issued = _parse_moment(row.get("regenerated_at")) or _parse_moment(row.get("created_at"))
|
||||
expires = _parse_moment(row.get("expires_at"))
|
||||
if expires is None:
|
||||
return 0 if settings.PAT_ALLOW_NON_EXPIRING else None
|
||||
if issued is None:
|
||||
return None
|
||||
days = round((expires - issued).total_seconds() / 86400)
|
||||
return max(1, min(days, settings.PAT_MAX_LIFETIME_DAYS))
|
||||
|
||||
|
||||
def _client_ip(request) -> Optional[str]:
|
||||
# Flask exposes remote_addr; Starlette exposes client.host.
|
||||
ip = getattr(request, "remote_addr", None)
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -223,6 +223,120 @@ class TestEndToEnd:
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestRegenerate:
|
||||
def _regen(self, client, token_id, **body):
|
||||
with _session():
|
||||
return client.post(f"/api/user/tokens/{token_id}/regenerate", json=body)
|
||||
|
||||
def test_new_secret_works_old_one_stops_and_the_rest_is_kept(self, client, db):
|
||||
created = json.loads(
|
||||
_create(client, scopes=["prompts:read"], resource_filter={"prompts": [AGENT_A]}).data
|
||||
)
|
||||
old, token_id = created["token"], created["personal_access_token"]["id"]
|
||||
response = self._regen(client, token_id)
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.data)
|
||||
new, public = body["token"], body["personal_access_token"]
|
||||
assert new.startswith("dgpt_pat_") and new != old
|
||||
assert public["id"] == token_id and public["name"] == "ci"
|
||||
assert public["scopes"] == ["prompts:read"]
|
||||
assert public["resource_filter"] == {"prompts": [AGENT_A]}
|
||||
assert public["token_prefix"] == new[:15]
|
||||
assert public["regenerated_at"] is not None
|
||||
assert public["last_used_at"] is None
|
||||
|
||||
assert client.get("/api/user/me", headers={"Authorization": f"Bearer {old}"}).status_code == 401
|
||||
me = client.get("/api/user/me", headers={"Authorization": f"Bearer {new}"})
|
||||
assert me.status_code == 200
|
||||
assert json.loads(me.data)["token"]["id"] == token_id
|
||||
stored = db.execute(text("SELECT token_hash FROM personal_access_tokens")).scalar_one()
|
||||
assert stored == pat_tokens.hash_token(new)
|
||||
|
||||
def test_expiry_is_reset_to_the_original_lifetime(self, client, db):
|
||||
token_id = json.loads(_create(client, expires_in_days=30).data)["personal_access_token"]["id"]
|
||||
# 20 days in: 10 days left.
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE personal_access_tokens SET created_at = now() - interval '20 days', "
|
||||
"expires_at = now() + interval '10 days'"
|
||||
)
|
||||
)
|
||||
self._regen(client, token_id)
|
||||
days_left = db.execute(
|
||||
text("SELECT extract(epoch FROM expires_at - now()) / 86400 FROM personal_access_tokens")
|
||||
).scalar_one()
|
||||
assert 29.9 < float(days_left) < 30.1
|
||||
|
||||
def test_explicit_lifetime_is_honoured_and_capped(self, client, db):
|
||||
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
|
||||
assert self._regen(client, token_id, expires_in_days=7).status_code == 200
|
||||
days_left = db.execute(
|
||||
text("SELECT extract(epoch FROM expires_at - now()) / 86400 FROM personal_access_tokens")
|
||||
).scalar_one()
|
||||
assert 6.9 < float(days_left) < 7.1
|
||||
assert self._regen(client, token_id, expires_in_days=366).status_code == 400
|
||||
assert self._regen(client, token_id, expires_in_days=0).status_code == 400
|
||||
|
||||
def test_an_expired_token_can_be_renewed(self, client, db):
|
||||
created = json.loads(_create(client, expires_in_days=30).data)
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE personal_access_tokens SET created_at = now() - interval '31 days', "
|
||||
"expires_at = now() - interval '1 day'"
|
||||
)
|
||||
)
|
||||
old_headers = {"Authorization": f"Bearer {created['token']}"}
|
||||
assert client.get("/api/user/me", headers=old_headers).status_code == 401
|
||||
body = json.loads(self._regen(client, created["personal_access_token"]["id"]).data)
|
||||
assert body["personal_access_token"]["status"] == "active"
|
||||
headers = {"Authorization": f"Bearer {body['token']}"}
|
||||
assert client.get("/api/user/me", headers=headers).status_code == 200
|
||||
|
||||
def test_non_expiring_token_stays_non_expiring_only_while_the_operator_allows_it(
|
||||
self, client, db, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(pat_tokens.settings, "PAT_ALLOW_NON_EXPIRING", True)
|
||||
token_id = json.loads(_create(client, expires_in_days=0).data)["personal_access_token"]["id"]
|
||||
assert json.loads(self._regen(client, token_id).data)["personal_access_token"]["expires_at"] is None
|
||||
monkeypatch.setattr(pat_tokens.settings, "PAT_ALLOW_NON_EXPIRING", False)
|
||||
renewed = json.loads(self._regen(client, token_id).data)["personal_access_token"]
|
||||
assert renewed["expires_at"] is not None # falls back to the default lifetime
|
||||
|
||||
def test_revoked_foreign_unknown_and_malformed_tokens_are_not_found(self, client, db):
|
||||
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
|
||||
with _session(sub="bob"):
|
||||
assert client.post(f"/api/user/tokens/{token_id}/regenerate").status_code == 404
|
||||
with _session():
|
||||
client.delete(f"/api/user/tokens/{token_id}")
|
||||
assert self._regen(client, token_id).status_code == 404
|
||||
assert self._regen(client, AGENT_A).status_code == 404
|
||||
assert self._regen(client, f"urn:uuid:{AGENT_A}").status_code == 404
|
||||
|
||||
def test_a_token_cannot_regenerate_itself_or_others(self, client, db):
|
||||
created = json.loads(_create(client, scopes=list(pat_tokens.SCOPES)).data)
|
||||
headers = {"Authorization": f"Bearer {created['token']}"}
|
||||
token_id = created["personal_access_token"]["id"]
|
||||
response = client.post(f"/api/user/tokens/{token_id}/regenerate", headers=headers)
|
||||
assert response.status_code == 403
|
||||
assert json.loads(response.data)["error"] == "not_available_to_tokens"
|
||||
|
||||
def test_requires_a_session_and_an_object_body(self, client, db):
|
||||
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
|
||||
with patch("docsgpt.app.handle_auth", return_value=None):
|
||||
assert client.post(f"/api/user/tokens/{token_id}/regenerate").status_code == 401
|
||||
with _session():
|
||||
assert client.post(f"/api/user/tokens/{token_id}/regenerate", json=[1]).status_code == 400
|
||||
|
||||
def test_audited_without_the_secret(self, client, db):
|
||||
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
|
||||
self._regen(client, token_id)
|
||||
metadata = db.execute(
|
||||
text("SELECT metadata FROM auth_events WHERE event = 'pat_regenerated'")
|
||||
).scalar_one()
|
||||
assert metadata["token_id"] == token_id
|
||||
assert "dgpt_pat_" not in json.dumps(metadata)
|
||||
|
||||
|
||||
class TestRevoke:
|
||||
def test_cannot_revoke_someone_elses_token(self, client, db):
|
||||
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
|
||||
|
||||
@@ -91,6 +91,7 @@ class TestClassification:
|
||||
("/api/user/tokens", "POST"),
|
||||
("/api/user/tokens", "GET"),
|
||||
("/api/user/tokens/<string:token_id>", "DELETE"),
|
||||
("/api/user/tokens/<string:token_id>/regenerate", "POST"),
|
||||
("/api/admin/users", "GET"),
|
||||
("/api/admin/tokens/<string:token_id>", "DELETE"),
|
||||
("/api/generate_token", "GET"),
|
||||
|
||||
@@ -179,3 +179,29 @@ class TestCountAndUsage:
|
||||
assert repo.get(str(row["id"]))["last_used_ip"] == "10.0.0.1"
|
||||
repo.touch_last_used(str(row["id"]), "10.0.0.3", min_interval_seconds=0)
|
||||
assert repo.get(str(row["id"]))["last_used_ip"] == "10.0.0.3"
|
||||
|
||||
|
||||
class TestRegenerate:
|
||||
def test_swaps_the_secret_and_resets_expiry_and_usage(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
row = _create(repo, expires_at=datetime.now(timezone.utc) - timedelta(days=1))
|
||||
repo.touch_last_used(str(row["id"]), "10.0.0.1")
|
||||
future = datetime.now(timezone.utc) + timedelta(days=30)
|
||||
renewed = repo.regenerate(
|
||||
str(row["id"]), "u1", token_hash="h-new", token_prefix="dgpt_pat_new123", expires_at=future
|
||||
)
|
||||
assert renewed["id"] == row["id"] and renewed["name"] == "ci"
|
||||
assert renewed["token_prefix"] == "dgpt_pat_new123"
|
||||
assert renewed["regenerated_at"] is not None
|
||||
assert renewed["last_used_at"] is None and renewed["last_used_ip"] is None
|
||||
assert repo.find_active_by_hash("h1") is None
|
||||
assert repo.find_active_by_hash("h-new")["id"] == row["id"]
|
||||
|
||||
def test_owner_scoped_and_never_revives_a_revoked_token(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
row = _create(repo)
|
||||
kwargs = dict(token_hash="h-new", token_prefix="dgpt_pat_new123", expires_at=None)
|
||||
assert repo.regenerate(str(row["id"]), "someone-else", **kwargs) is None
|
||||
repo.revoke(str(row["id"]), "u1")
|
||||
assert repo.regenerate(str(row["id"]), "u1", **kwargs) is None
|
||||
assert repo.find_active_by_hash("h-new") is None
|
||||
Reference in new issue
Block a user