feat: add PAT storage and API access fields

This commit is contained in:
Anish Sarkar 2026-06-19 20:26:28 +05:30
parent 7b981b6d16
commit 4463990ca4
4 changed files with 136 additions and 0 deletions

View file

@ -84,6 +84,9 @@ SECRET_KEY=SECRET
# JWT Token Lifetimes (optional, defaults shown)
# ACCESS_TOKEN_LIFETIME_SECONDS=86400 # 1 day
# REFRESH_TOKEN_LIFETIME_SECONDS=1209600 # 2 weeks
# Personal Access Tokens (PATs). Empty/unset = no maximum; users may create
# never-expiring PATs. When set, PAT creation requires an expiry <= this many days.
# PAT_MAX_EXPIRY_DAYS=
NEXT_FRONTEND_URL=http://localhost:3000

View file

@ -0,0 +1,83 @@
"""Add personal access tokens and search-space API access gate.
Revision ID: 166
Revises: 165
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "166"
down_revision: str | None = "165"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS personal_access_tokens (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL,
token_prefix VARCHAR(16) NOT NULL,
label VARCHAR NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE,
last_used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);
"""
)
op.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS ix_personal_access_tokens_token_hash "
"ON personal_access_tokens (token_hash)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_personal_access_tokens_user_id "
"ON personal_access_tokens (user_id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_personal_access_tokens_id "
"ON personal_access_tokens (id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_personal_access_tokens_created_at "
"ON personal_access_tokens (created_at)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_personal_access_tokens_expires_at "
"ON personal_access_tokens (expires_at)"
)
bind = op.get_bind()
api_access_column_exists = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'searchspaces'
AND column_name = 'api_access_enabled'
)
"""
)
).scalar()
op.execute(
"ALTER TABLE searchspaces ADD COLUMN IF NOT EXISTS "
"api_access_enabled BOOLEAN NOT NULL DEFAULT false"
)
if not api_access_column_exists:
op.execute("UPDATE searchspaces SET api_access_enabled = true")
def downgrade() -> None:
op.execute(
"ALTER TABLE searchspaces DROP COLUMN IF EXISTS api_access_enabled"
)
op.execute("DROP TABLE IF EXISTS personal_access_tokens")

View file

@ -919,6 +919,10 @@ class Config:
REFRESH_TOKEN_LIFETIME_SECONDS = int(
os.getenv("REFRESH_TOKEN_LIFETIME_SECONDS", str(14 * 24 * 60 * 60)) # 2 weeks
)
_PAT_MAX_EXPIRY_DAYS = os.getenv("PAT_MAX_EXPIRY_DAYS", "").strip()
PAT_MAX_EXPIRY_DAYS = (
int(_PAT_MAX_EXPIRY_DAYS) if _PAT_MAX_EXPIRY_DAYS else None
)
# ETL Service
ETL_SERVICE = os.getenv("ETL_SERVICE")

View file

@ -368,6 +368,9 @@ class Permission(StrEnum):
SETTINGS_UPDATE = "settings:update"
SETTINGS_DELETE = "settings:delete" # Delete the entire search space
# API Access
API_ACCESS_MANAGE = "api_access:manage"
# Public Sharing
PUBLIC_SHARING_VIEW = "public_sharing:view"
PUBLIC_SHARING_CREATE = "public_sharing:create"
@ -1693,6 +1696,9 @@ class SearchSpace(BaseModel, TimestampMixin):
citations_enabled = Column(
Boolean, nullable=False, default=True
) # Enable/disable citations
api_access_enabled = Column(
Boolean, nullable=False, default=False, server_default="false"
)
qna_custom_instructions = Column(
Text, nullable=True, default=""
) # User's custom instructions
@ -2330,6 +2336,11 @@ if config.AUTH_TYPE == "GOOGLE":
back_populates="user",
cascade="all, delete-orphan",
)
personal_access_tokens = relationship(
"PersonalAccessToken",
back_populates="user",
cascade="all, delete-orphan",
)
else:
@ -2462,6 +2473,11 @@ else:
back_populates="user",
cascade="all, delete-orphan",
)
personal_access_tokens = relationship(
"PersonalAccessToken",
back_populates="user",
cascade="all, delete-orphan",
)
class AgentActionLog(BaseModel):
@ -2712,6 +2728,36 @@ class RefreshToken(Base, TimestampMixin):
return not self.is_expired and not self.is_revoked
class PersonalAccessToken(BaseModel, TimestampMixin):
"""
Stores hashed Personal Access Tokens for programmatic API access.
Plaintext tokens are shown once on creation and are never persisted.
"""
__tablename__ = "personal_access_tokens"
user_id = Column(
UUID(as_uuid=True),
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user = relationship("User", back_populates="personal_access_tokens")
token_hash = Column(String(64), unique=True, nullable=False, index=True)
token_prefix = Column(String(16), nullable=False)
label = Column(String, nullable=False)
expires_at = Column(TIMESTAMP(timezone=True), nullable=True, index=True)
last_used_at = Column(TIMESTAMP(timezone=True), nullable=True)
@property
def is_expired(self) -> bool:
return self.expires_at is not None and datetime.now(UTC) >= self.expires_at
@property
def is_valid(self) -> bool:
return not self.is_expired
# Register model packages that live outside this file so their classes
# are present in Base.metadata before configure_mappers() resolves any
# string-based relationship() references.