diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index a6b2b30a3..6a8f991e4 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -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 diff --git a/surfsense_backend/alembic/versions/166_add_pat_and_api_access.py b/surfsense_backend/alembic/versions/166_add_pat_and_api_access.py new file mode 100644 index 000000000..b49b099a6 --- /dev/null +++ b/surfsense_backend/alembic/versions/166_add_pat_and_api_access.py @@ -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") diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 63be54654..b998f05cf 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -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") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 3f098d5d2..a65a964fd 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -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.