mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-05 00:13:01 +00:00
feat(pat): add personal access token storage and settings
A personal_access_tokens table (migration 0032) holds scoped user-level API credentials. Only the SHA-256 of the secret is stored, like device session tokens. Lookups exclude revoked and expired tokens and the tokens of deactivated users. PAT_* settings cover the feature switch, default and maximum lifetime, the operator opt-in for non-expiring tokens and the per-user cap.
This commit is contained in:
1 parent
0952a05aab
commit
22ecc0aee3
7 files changed
+494
No files matched your search
@@ -101,3 +101,11 @@ MICROSOFT_AUTHORITY=https://{tenantId}.ciamlogin.com/{tenantId}
|
||||
# pair with OIDC_USER_ID_CLAIM=email so SCIM userName matches the OIDC user id)
|
||||
# SCIM_ENABLED=false
|
||||
# SCIM_TOKEN=<long random bearer token presented by the IdP's SCIM client>
|
||||
|
||||
# Personal access tokens (scoped API tokens for CLI and CI/CD; Settings → Access Tokens).
|
||||
# Available with AUTH_TYPE=oidc or unset.
|
||||
# PAT_ENABLED=true
|
||||
# PAT_DEFAULT_LIFETIME_DAYS=90
|
||||
# PAT_MAX_LIFETIME_DAYS=365
|
||||
# PAT_ALLOW_NON_EXPIRING=false
|
||||
# PAT_MAX_PER_USER=25
|
||||
@@ -132,6 +132,36 @@ Type `str`, default unset.
|
||||
|
||||
Bearer token for IdP SCIM clients (required when SCIM is enabled).
|
||||
|
||||
### `PAT_ENABLED`
|
||||
|
||||
Type `bool`, default `true`.
|
||||
|
||||
Allow users to create personal access tokens. Tokens are only issued under AUTH_TYPE=oidc or unset (None); simple_jwt and session_jwt have no stable user identity to bind a token to.
|
||||
|
||||
### `PAT_DEFAULT_LIFETIME_DAYS`
|
||||
|
||||
Type `int`, default `90`, must be `> 0`.
|
||||
|
||||
Lifetime of a personal access token created without an explicit expiry.
|
||||
|
||||
### `PAT_MAX_LIFETIME_DAYS`
|
||||
|
||||
Type `int`, default `365`, must be `> 0`.
|
||||
|
||||
Longest lifetime a user may request for a personal access token.
|
||||
|
||||
### `PAT_ALLOW_NON_EXPIRING`
|
||||
|
||||
Type `bool`, default `false`.
|
||||
|
||||
Let users create personal access tokens that never expire. Off by default.
|
||||
|
||||
### `PAT_MAX_PER_USER`
|
||||
|
||||
Type `int`, default `25`, must be `> 0`.
|
||||
|
||||
Maximum number of live personal access tokens per user.
|
||||
|
||||
|
||||
## LLM providers
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""0032 personal access tokens — scoped, user-level API credentials.
|
||||
|
||||
A personal access token (PAT) authenticates its owner against the management
|
||||
API for CLI and CI/CD use. Only the SHA-256 of the secret is stored, mirroring
|
||||
``devices.token_hash``: the plaintext is shown once at creation and a database
|
||||
leak cannot reconstruct it. ``token_prefix`` keeps the first characters so a
|
||||
user can tell their tokens apart in the UI.
|
||||
|
||||
``scopes`` is the server-side grant list (never read from the credential
|
||||
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.
|
||||
|
||||
``user_id`` is the auth ``sub``; no FK or trigger, mirroring ``devices`` and
|
||||
``user_roles`` so a token row never blocks user deletion.
|
||||
|
||||
Revision ID: 0032_personal_access_tokens
|
||||
Revises: 0031_token_usage_cache_tokens
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0032_personal_access_tokens"
|
||||
down_revision: Union[str, None] = "0031_token_usage_cache_tokens"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS personal_access_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
scopes TEXT[] NOT NULL DEFAULT '{}',
|
||||
resource_filter JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'revoked')),
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
last_used_ip TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoke_reason TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
# Looked up on every PAT-authenticated request.
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS personal_access_tokens_hash_uidx "
|
||||
"ON personal_access_tokens(token_hash);"
|
||||
)
|
||||
# Names are unique among a user's live tokens; a revoked name can be reused.
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS personal_access_tokens_user_name_uidx "
|
||||
"ON personal_access_tokens(user_id, name) WHERE status = 'active';"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS personal_access_tokens_user_idx "
|
||||
"ON personal_access_tokens(user_id, created_at DESC);"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS personal_access_tokens;")
|
||||
@@ -85,6 +85,28 @@ class AuthSettings(SettingsGroup):
|
||||
default=None, description="Bearer token for IdP SCIM clients (required when SCIM is enabled)."
|
||||
)
|
||||
|
||||
# Personal access tokens: scoped user-level API credentials for CLI and CI/CD use.
|
||||
PAT_ENABLED: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Allow users to create personal access tokens. Tokens are only issued under AUTH_TYPE=oidc or "
|
||||
"unset (None); simple_jwt and session_jwt have no stable user identity to bind a token to."
|
||||
),
|
||||
)
|
||||
PAT_DEFAULT_LIFETIME_DAYS: int = Field(
|
||||
default=90, gt=0, description="Lifetime of a personal access token created without an explicit expiry."
|
||||
)
|
||||
PAT_MAX_LIFETIME_DAYS: int = Field(
|
||||
default=365, gt=0, description="Longest lifetime a user may request for a personal access token."
|
||||
)
|
||||
PAT_ALLOW_NON_EXPIRING: bool = Field(
|
||||
default=False,
|
||||
description="Let users create personal access tokens that never expire. Off by default.",
|
||||
)
|
||||
PAT_MAX_PER_USER: int = Field(
|
||||
default=25, gt=0, description="Maximum number of live personal access tokens per user."
|
||||
)
|
||||
|
||||
@field_validator("AUTH_TYPE", mode="before")
|
||||
@classmethod
|
||||
def _normalize_auth_type(cls, v):
|
||||
@@ -99,4 +121,6 @@ class AuthSettings(SettingsGroup):
|
||||
raise ValueError(f"AUTH_TYPE=oidc requires settings: {', '.join(missing)}")
|
||||
if self.SCIM_ENABLED and not self.SCIM_TOKEN:
|
||||
raise ValueError("SCIM_ENABLED requires settings: SCIM_TOKEN")
|
||||
if self.PAT_DEFAULT_LIFETIME_DAYS > self.PAT_MAX_LIFETIME_DAYS:
|
||||
raise ValueError("PAT_DEFAULT_LIFETIME_DAYS must not exceed PAT_MAX_LIFETIME_DAYS")
|
||||
return self
|
||||
@@ -1079,3 +1079,44 @@ device_auto_approve_patterns_table = Table(
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
UniqueConstraint("device_id", "user_id", "pattern", name="device_auto_approve_uidx"),
|
||||
)
|
||||
|
||||
# --- Personal access tokens (migration 0032) --------------------------------
|
||||
# Scoped user-level API credentials. Only the SHA-256 of the secret is stored.
|
||||
|
||||
personal_access_tokens_table = Table(
|
||||
"personal_access_tokens",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("name", Text, nullable=False),
|
||||
Column("token_hash", Text, nullable=False),
|
||||
Column("token_prefix", Text, nullable=False),
|
||||
Column("scopes", ARRAY(Text), nullable=False, server_default="{}"),
|
||||
Column("resource_filter", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
|
||||
Column("status", Text, nullable=False, server_default="active"),
|
||||
Column("expires_at", DateTime(timezone=True)),
|
||||
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("revoked_at", DateTime(timezone=True)),
|
||||
Column("revoke_reason", Text),
|
||||
CheckConstraint("status IN ('active', 'revoked')", name="personal_access_tokens_status_check"),
|
||||
)
|
||||
|
||||
Index(
|
||||
"personal_access_tokens_hash_uidx",
|
||||
personal_access_tokens_table.c.token_hash,
|
||||
unique=True,
|
||||
)
|
||||
Index(
|
||||
"personal_access_tokens_user_name_uidx",
|
||||
personal_access_tokens_table.c.user_id,
|
||||
personal_access_tokens_table.c.name,
|
||||
unique=True,
|
||||
postgresql_where=personal_access_tokens_table.c.status == "active",
|
||||
)
|
||||
Index(
|
||||
"personal_access_tokens_user_idx",
|
||||
personal_access_tokens_table.c.user_id,
|
||||
personal_access_tokens_table.c.created_at.desc(),
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Repository for the ``personal_access_tokens`` table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
class PersonalAccessTokensRepository:
|
||||
"""CRUD for personal access tokens. Callers hash the secret; only the hash is stored."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
user_id: str,
|
||||
name: str,
|
||||
*,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
scopes: list[str],
|
||||
resource_filter: Optional[dict] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> dict:
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
f"""
|
||||
INSERT INTO personal_access_tokens (
|
||||
user_id, name, token_hash, token_prefix, scopes,
|
||||
resource_filter, expires_at
|
||||
) VALUES (
|
||||
:user_id, :name, :token_hash, :token_prefix, :scopes,
|
||||
CAST(:resource_filter AS jsonb), :expires_at
|
||||
) RETURNING {_PUBLIC_COLUMNS}
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"token_hash": token_hash,
|
||||
"token_prefix": token_prefix,
|
||||
"scopes": list(scopes),
|
||||
"resource_filter": json.dumps(resource_filter or {}),
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
).fetchone()
|
||||
return row_to_dict(row)
|
||||
|
||||
def get(self, token_id: str, user_id: Optional[str] = None) -> Optional[dict]:
|
||||
sql = f"SELECT {_PUBLIC_COLUMNS} FROM personal_access_tokens WHERE id = CAST(:id AS uuid)"
|
||||
params: dict = {"id": token_id}
|
||||
if user_id is not None:
|
||||
sql += " AND user_id = :user_id"
|
||||
params["user_id"] = user_id
|
||||
row = self._conn.execute(text(sql), params).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_for_user(self, user_id: str, *, include_revoked: bool = False) -> list[dict]:
|
||||
sql = f"SELECT {_PUBLIC_COLUMNS} FROM personal_access_tokens WHERE user_id = :user_id"
|
||||
if not include_revoked:
|
||||
sql += " AND status = 'active'"
|
||||
sql += " ORDER BY created_at DESC"
|
||||
result = self._conn.execute(text(sql), {"user_id": user_id})
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def count_active(self, user_id: str) -> int:
|
||||
"""Live tokens only: revoked and expired rows don't count against the per-user cap."""
|
||||
return self._conn.execute(
|
||||
text(
|
||||
"SELECT count(*) FROM personal_access_tokens "
|
||||
"WHERE user_id = :user_id AND status = 'active' "
|
||||
"AND (expires_at IS NULL OR expires_at > now())"
|
||||
),
|
||||
{"user_id": user_id},
|
||||
).scalar_one()
|
||||
|
||||
def name_in_use(self, user_id: str, name: str) -> bool:
|
||||
return (
|
||||
self._conn.execute(
|
||||
text(
|
||||
"SELECT 1 FROM personal_access_tokens "
|
||||
"WHERE user_id = :user_id AND name = :name AND status = 'active' LIMIT 1"
|
||||
),
|
||||
{"user_id": user_id, "name": name},
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
def find_active_by_hash(self, token_hash: str) -> Optional[dict]:
|
||||
"""Resolve the credential on each request.
|
||||
|
||||
Revoked and expired tokens never match, and neither do the tokens of a
|
||||
deactivated user (admin or SCIM), so deactivation needs no token sweep
|
||||
and reactivation restores them.
|
||||
"""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
f"SELECT {_PUBLIC_COLUMNS} FROM personal_access_tokens pat "
|
||||
"WHERE token_hash = :token_hash AND status = 'active' "
|
||||
"AND (expires_at IS NULL OR expires_at > now()) "
|
||||
"AND NOT EXISTS (SELECT 1 FROM users u "
|
||||
"WHERE u.user_id = pat.user_id AND u.active = false) "
|
||||
"LIMIT 1"
|
||||
),
|
||||
{"token_hash": token_hash},
|
||||
).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def touch_last_used(self, token_id: str, ip: Optional[str], *, min_interval_seconds: int = 60) -> None:
|
||||
"""Record use, at most once per ``min_interval_seconds`` so hot tokens don't write per request."""
|
||||
self._conn.execute(
|
||||
text(
|
||||
"UPDATE personal_access_tokens "
|
||||
"SET last_used_at = now(), last_used_ip = :ip "
|
||||
"WHERE id = CAST(:id AS uuid) AND (last_used_at IS NULL "
|
||||
"OR last_used_at <= now() - make_interval(secs => :min_interval))"
|
||||
),
|
||||
{"id": token_id, "ip": ip, "min_interval": min_interval_seconds},
|
||||
)
|
||||
|
||||
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 = (
|
||||
"UPDATE personal_access_tokens "
|
||||
"SET status = 'revoked', revoked_at = now(), revoke_reason = :reason "
|
||||
"WHERE id = CAST(:id AS uuid) AND status = 'active'"
|
||||
)
|
||||
params: dict = {"id": token_id, "reason": reason}
|
||||
if user_id is not None:
|
||||
sql += " AND user_id = :user_id"
|
||||
params["user_id"] = user_id
|
||||
return self._conn.execute(text(sql), params).rowcount > 0
|
||||
|
||||
def revoke_all_for_user(self, user_id: str, *, reason: str = "admin_revoked") -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"UPDATE personal_access_tokens "
|
||||
"SET status = 'revoked', revoked_at = now(), revoke_reason = :reason "
|
||||
"WHERE user_id = :user_id AND status = 'active'"
|
||||
),
|
||||
{"user_id": user_id, "reason": reason},
|
||||
)
|
||||
return result.rowcount
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tests for PersonalAccessTokensRepository against a real Postgres."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from docsgpt.storage.db.repositories.personal_access_tokens import (
|
||||
PersonalAccessTokensRepository,
|
||||
)
|
||||
|
||||
|
||||
def _create(repo, user_id="u1", name="ci", token_hash="h1", **kwargs):
|
||||
kwargs.setdefault("scopes", ["agents:read"])
|
||||
return repo.create(
|
||||
user_id, name, token_hash=token_hash, token_prefix="dgpt_pat_abc123", **kwargs
|
||||
)
|
||||
|
||||
|
||||
class TestCreateAndRead:
|
||||
def test_create_returns_public_columns_only(self, pg_conn):
|
||||
row = _create(
|
||||
PersonalAccessTokensRepository(pg_conn),
|
||||
resource_filter={"agents": ["00000000-0000-0000-0000-000000000001"]},
|
||||
)
|
||||
assert "token_hash" not in row
|
||||
assert row["status"] == "active"
|
||||
assert row["scopes"] == ["agents:read"]
|
||||
assert row["resource_filter"] == {"agents": ["00000000-0000-0000-0000-000000000001"]}
|
||||
assert row["expires_at"] is None
|
||||
|
||||
def test_get_is_owner_scoped(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
row = _create(repo)
|
||||
assert repo.get(str(row["id"]), "u1")["name"] == "ci"
|
||||
assert repo.get(str(row["id"]), "someone-else") is None
|
||||
assert repo.get(str(row["id"]))["user_id"] == "u1"
|
||||
|
||||
def test_list_hides_revoked_by_default(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
kept = _create(repo, name="kept", token_hash="h1")
|
||||
gone = _create(repo, name="gone", token_hash="h2")
|
||||
repo.revoke(str(gone["id"]), "u1")
|
||||
assert [r["id"] for r in repo.list_for_user("u1")] == [kept["id"]]
|
||||
assert len(repo.list_for_user("u1", include_revoked=True)) == 2
|
||||
assert repo.list_for_user("u2") == []
|
||||
|
||||
|
||||
class TestUniqueness:
|
||||
def test_duplicate_active_name_rejected(self, pg_conn):
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
_create(repo, token_hash="h1")
|
||||
with pytest.raises(IntegrityError), pg_conn.begin_nested():
|
||||
_create(repo, token_hash="h2")
|
||||
|
||||
def test_revoked_name_can_be_reused(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
first = _create(repo, token_hash="h1")
|
||||
repo.revoke(str(first["id"]), "u1")
|
||||
assert not repo.name_in_use("u1", "ci")
|
||||
assert _create(repo, token_hash="h2")["name"] == "ci"
|
||||
|
||||
def test_same_name_for_other_user_is_fine(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
_create(repo, user_id="u1", token_hash="h1")
|
||||
_create(repo, user_id="u2", token_hash="h2")
|
||||
assert repo.name_in_use("u1", "ci") and repo.name_in_use("u2", "ci")
|
||||
|
||||
|
||||
class TestFindActiveByHash:
|
||||
def test_finds_live_token(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
_create(repo)
|
||||
found = repo.find_active_by_hash("h1")
|
||||
assert found["user_id"] == "u1"
|
||||
assert "token_hash" not in found
|
||||
|
||||
def test_unknown_hash(self, pg_conn):
|
||||
assert PersonalAccessTokensRepository(pg_conn).find_active_by_hash("nope") is None
|
||||
|
||||
def test_revoked_token_never_matches(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
row = _create(repo)
|
||||
repo.revoke(str(row["id"]), "u1")
|
||||
assert repo.find_active_by_hash("h1") is None
|
||||
|
||||
def test_expired_token_never_matches(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
_create(repo, expires_at=datetime.now(timezone.utc) - timedelta(seconds=1))
|
||||
assert repo.find_active_by_hash("h1") is None
|
||||
|
||||
def test_future_expiry_matches(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
_create(repo, expires_at=datetime.now(timezone.utc) + timedelta(days=1))
|
||||
assert repo.find_active_by_hash("h1") is not None
|
||||
|
||||
def test_deactivated_user_token_never_matches(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
_create(repo)
|
||||
pg_conn.execute(
|
||||
text("INSERT INTO users (user_id, active) VALUES ('u1', false)")
|
||||
)
|
||||
assert repo.find_active_by_hash("h1") is None
|
||||
pg_conn.execute(text("UPDATE users SET active = true WHERE user_id = 'u1'"))
|
||||
assert repo.find_active_by_hash("h1") is not None
|
||||
|
||||
|
||||
class TestRevoke:
|
||||
def test_revoke_is_owner_scoped(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
row = _create(repo)
|
||||
assert repo.revoke(str(row["id"]), "someone-else") is False
|
||||
assert repo.revoke(str(row["id"]), "u1") is True
|
||||
assert repo.revoke(str(row["id"]), "u1") is False
|
||||
stored = repo.get(str(row["id"]))
|
||||
assert stored["status"] == "revoked"
|
||||
assert stored["revoke_reason"] == "user_revoked"
|
||||
assert stored["revoked_at"] is not None
|
||||
|
||||
def test_admin_revoke_needs_no_owner(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
row = _create(repo)
|
||||
assert repo.revoke(str(row["id"]), reason="admin_revoked") is True
|
||||
assert repo.get(str(row["id"]))["revoke_reason"] == "admin_revoked"
|
||||
|
||||
def test_revoke_all_for_user(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
_create(repo, name="a", token_hash="h1")
|
||||
_create(repo, name="b", token_hash="h2")
|
||||
_create(repo, user_id="u2", token_hash="h3")
|
||||
assert repo.revoke_all_for_user("u1") == 2
|
||||
assert repo.list_for_user("u1") == []
|
||||
assert len(repo.list_for_user("u2")) == 1
|
||||
|
||||
|
||||
class TestCountAndUsage:
|
||||
def test_count_active_ignores_revoked_and_expired(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
_create(repo, name="live", token_hash="h1")
|
||||
_create(
|
||||
repo,
|
||||
name="expired",
|
||||
token_hash="h2",
|
||||
expires_at=datetime.now(timezone.utc) - timedelta(days=1),
|
||||
)
|
||||
revoked = _create(repo, name="revoked", token_hash="h3")
|
||||
repo.revoke(str(revoked["id"]), "u1")
|
||||
assert repo.count_active("u1") == 1
|
||||
|
||||
def test_touch_last_used_is_throttled(self, pg_conn):
|
||||
repo = PersonalAccessTokensRepository(pg_conn)
|
||||
row = _create(repo)
|
||||
repo.touch_last_used(str(row["id"]), "10.0.0.1")
|
||||
first = repo.get(str(row["id"]))
|
||||
assert first["last_used_ip"] == "10.0.0.1"
|
||||
repo.touch_last_used(str(row["id"]), "10.0.0.2")
|
||||
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"
|
||||
Reference in new issue
Block a user