Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions backend/alembic/versions/g6h7i8j9k0l1_add_runner_tokens_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""add runner_tokens table

Revision ID: g6h7i8j9k0l1
Revises: b2c3d4e5f6a7
Create Date: 2026-05-06 00:00:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "g6h7i8j9k0l1"
down_revision: Union[str, None] = "b2c3d4e5f6a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"runner_tokens",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("hashed_token", sa.String(128), nullable=False),
sa.Column("salt", sa.String(64), nullable=False),
sa.Column("label", sa.String(255), nullable=False),
sa.Column("scopes", sa.JSON(), nullable=False),
sa.Column(
"created_by_user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("last_used_at", sa.DateTime(), nullable=True),
sa.Column("revoked_at", sa.DateTime(), nullable=True),
)
op.create_index("idx_runner_tokens_hashed_token", "runner_tokens", ["hashed_token"], unique=True)
op.create_index("idx_runner_tokens_created_by_user_id", "runner_tokens", ["created_by_user_id"])


def downgrade() -> None:
op.drop_index("idx_runner_tokens_created_by_user_id", table_name="runner_tokens")
op.drop_index("idx_runner_tokens_hashed_token", table_name="runner_tokens")
op.drop_table("runner_tokens")
101 changes: 99 additions & 2 deletions backend/app/api/auth.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
"""API endpoints for Authentication"""

from fastapi import APIRouter, Depends, HTTPException, status
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.dependencies import get_current_user
from app.auth.dependencies import get_current_user, require_admin
from app.auth.security import create_access_token, verify_password
from app.crud.audit_log import create_audit_entry
from app.crud.runner_token import (
create_runner_token,
get_runner_token_by_id,
list_runner_tokens,
revoke_runner_token,
)
from app.crud.user import create_user, get_user_by_email
from app.db.session import get_db
from app.models.user import User
from app.schemas.runner_token import RunnerTokenCreate, RunnerTokenIssueResponse, RunnerTokenResponse
from app.schemas.user import TokenResponse, UserCreate, UserLogin, UserResponse

router = APIRouter()
Expand Down Expand Up @@ -42,3 +52,90 @@ async def login(user_login: UserLogin, db: AsyncSession = Depends(get_db)):
async def get_me(current_user: User = Depends(get_current_user)):
"""Return the currently authenticated user"""
return current_user


# ---------------------------------------------------------------------------
# Runner-token endpoints
# ---------------------------------------------------------------------------


@router.post(
"/auth/runner-tokens",
response_model=RunnerTokenIssueResponse,
status_code=status.HTTP_201_CREATED,
tags=["auth"],
)
async def issue_runner_token(
body: RunnerTokenCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""Issue a new machine runner token (admin only).

The plaintext token is returned **once** in the response and is never stored.
Store it securely immediately after creation.
"""
token, plaintext = await create_runner_token(
db,
label=body.label,
scopes=body.scopes,
created_by_user_id=current_user.id,
)
await create_audit_entry(
db,
user_id=current_user.id,
action="auth.runner_token.issue",
resource_type="runner_token",
resource_id=str(token.id),
details={"label": token.label, "scopes": token.scopes},
)
return RunnerTokenIssueResponse(
id=token.id,
label=token.label,
scopes=token.scopes,
created_at=token.created_at,
last_used_at=token.last_used_at,
revoked_at=token.revoked_at,
token=plaintext,
)


@router.get("/auth/runner-tokens", response_model=list[RunnerTokenResponse], tags=["auth"])
async def list_runner_tokens_endpoint(
include_revoked: bool = Query(False, description="Include revoked tokens in the response"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""List all runner tokens (admin only). Revoked tokens are hidden by default."""
tokens = await list_runner_tokens(db, include_revoked=include_revoked)
return tokens


@router.delete(
"/auth/runner-tokens/{token_id}",
status_code=status.HTTP_204_NO_CONTENT,
tags=["auth"],
)
async def delete_runner_token(
token_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""Revoke a runner token (admin only).

Returns 404 if the token does not exist and 409 if it is already revoked.
"""
token = await get_runner_token_by_id(db, token_id)
if token is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Runner token not found")
if token.revoked_at is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Runner token already revoked")
await revoke_runner_token(db, token_id)
await create_audit_entry(
db,
user_id=current_user.id,
action="auth.runner_token.revoke",
resource_type="runner_token",
resource_id=str(token.id),
details={"label": token.label},
)
82 changes: 80 additions & 2 deletions backend/app/auth/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from fastapi import Depends, HTTPException, status
from collections.abc import Callable
from typing import Any

from fastapi import Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.security import decode_access_token
from app.auth.security import decode_access_token, hash_runner_token
from app.db.session import get_db
from app.models.runner_token import RunnerToken
from app.models.user import User, UserRole

bearer_scheme = HTTPBearer()
Expand Down Expand Up @@ -59,3 +63,77 @@ async def require_admin(current_user: User = Depends(get_current_user)) -> User:
detail="Admin role required",
)
return current_user


# ---------------------------------------------------------------------------
# Runner-token dependencies
# ---------------------------------------------------------------------------

_RUNNER_TOKEN_PREFIX = "bgstm_runner_"


async def get_current_runner_token(
authorization: str = Header(...),
db: AsyncSession = Depends(get_db),
) -> RunnerToken:
"""Resolve ``Authorization: Bearer bgstm_runner_<...>`` to a RunnerToken.

Raises 401 if the header is missing/malformed, the token is unknown, or it
has been revoked. Updates ``last_used_at`` on every successful resolution.
"""
from app.crud.runner_token import update_last_used

# Parse "Bearer <value>"
parts = authorization.split(" ", 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")

raw_token = parts[1]
if not raw_token.startswith(_RUNNER_TOKEN_PREFIX):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not a runner token")

# We need the salt to re-derive the hash, so we must look up by prefix scan.
# The stored hash was derived as SHA-256(salt + plaintext). Because we only
# store the hash (not the plaintext), we cannot do a single-step lookup.
# Instead we perform a linear scan over *active* tokens — acceptable given the
# small expected cardinality of runner tokens.
from sqlalchemy import select

from app.models.runner_token import RunnerToken as RT

result = await db.execute(select(RT))
all_tokens = list(result.scalars().all())

matched: RunnerToken | None = None
for candidate in all_tokens:
if hash_runner_token(raw_token, candidate.salt) == candidate.hashed_token:
matched = candidate
break

if matched is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid runner token")

if matched.revoked_at is not None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Revoked runner token")

await update_last_used(db, matched)
return matched


def require_runner_scope(scope: str) -> Callable[..., Any]:
"""Return a FastAPI dependency that requires *scope* on the resolved runner token.

Usage::

@router.post(..., dependencies=[Depends(require_runner_scope("external_results:write"))])
"""

async def _dependency(token: RunnerToken = Depends(get_current_runner_token)) -> RunnerToken:
if scope not in (token.scopes or []):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Runner token does not have the required scope: {scope!r}",
)
return token

return _dependency
28 changes: 28 additions & 0 deletions backend/app/auth/security.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import hashlib
import secrets
from datetime import datetime, timedelta, timezone

import bcrypt
Expand Down Expand Up @@ -27,3 +29,29 @@ def decode_access_token(token: str) -> dict | None:
return payload
except jwt.PyJWTError:
return None


# ---------------------------------------------------------------------------
# Runner-token helpers
# ---------------------------------------------------------------------------

_RUNNER_TOKEN_PREFIX = "bgstm_runner_"


def generate_runner_token() -> str:
"""Generate a new plaintext runner token with the ``bgstm_runner_`` prefix."""
return _RUNNER_TOKEN_PREFIX + secrets.token_urlsafe(32)


def generate_token_salt() -> str:
"""Generate a fresh 16-byte hex salt for a runner token."""
return secrets.token_bytes(16).hex()


def hash_runner_token(plaintext: str, salt: str) -> str:
"""Return the salted SHA-256 hex digest of *plaintext*.

The salt is prepended to the plaintext before hashing so that two tokens
with identical values produce different digests.
"""
return hashlib.sha256((salt + plaintext).encode("utf-8")).hexdigest()
82 changes: 82 additions & 0 deletions backend/app/crud/runner_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""CRUD operations for RunnerToken (BGSTM#296)."""

from datetime import datetime
from uuid import UUID

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.security import generate_runner_token, generate_token_salt, hash_runner_token
from app.models.runner_token import RunnerToken


async def create_runner_token(
db: AsyncSession,
*,
label: str,
scopes: list[str],
created_by_user_id: UUID,
) -> tuple[RunnerToken, str]:
"""Create a new runner token.

Returns ``(RunnerToken model, plaintext_token)``. The plaintext is returned
**once** and is never persisted; callers must surface it to the admin immediately.
"""
plaintext = generate_runner_token()
salt = generate_token_salt()
hashed = hash_runner_token(plaintext, salt)

token = RunnerToken(
hashed_token=hashed,
salt=salt,
label=label,
scopes=scopes,
created_by_user_id=created_by_user_id,
)
db.add(token)
await db.commit()
await db.refresh(token)
return token, plaintext


async def get_runner_token_by_hash(db: AsyncSession, hashed: str) -> RunnerToken | None:
"""Look up a runner token by its hashed value."""
result = await db.execute(select(RunnerToken).where(RunnerToken.hashed_token == hashed))
return result.scalar_one_or_none()


async def get_runner_token_by_id(db: AsyncSession, token_id: UUID) -> RunnerToken | None:
"""Look up a runner token by its primary key."""
result = await db.execute(select(RunnerToken).where(RunnerToken.id == token_id))
return result.scalar_one_or_none()


async def list_runner_tokens(
db: AsyncSession,
*,
include_revoked: bool = False,
) -> list[RunnerToken]:
"""Return all runner tokens, optionally filtering out revoked ones."""
query = select(RunnerToken)
if not include_revoked:
query = query.where(RunnerToken.revoked_at.is_(None))
query = query.order_by(RunnerToken.created_at.desc())
result = await db.execute(query)
return list(result.scalars().all())


async def revoke_runner_token(db: AsyncSession, token_id: UUID) -> RunnerToken:
"""Set ``revoked_at`` on the token. Caller must verify the token exists first."""
token = await get_runner_token_by_id(db, token_id)
if token is None:
raise ValueError(f"RunnerToken {token_id} not found")
token.revoked_at = datetime.utcnow()
await db.commit()
await db.refresh(token)
return token


async def update_last_used(db: AsyncSession, token: RunnerToken) -> None:
"""Stamp ``last_used_at`` without refreshing the full object."""
token.last_used_at = datetime.utcnow()
await db.commit()
2 changes: 2 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .link import LinkSource, LinkType, RequirementTestCaseLink
from .notification import Notification, NotificationType
from .requirement import PriorityLevel, Requirement, RequirementStatus, RequirementType
from .runner_token import RunnerToken
from .suggestion import LinkSuggestion, SuggestionMethod, SuggestionStatus
from .test_case import AutomationStatus, TestCase, TestCaseStatus, TestCaseType
from .user import User, UserRole
Expand All @@ -21,6 +22,7 @@
"RequirementType",
"PriorityLevel",
"RequirementStatus",
"RunnerToken",
"TestCase",
"TestCaseType",
"TestCaseStatus",
Expand Down
Loading
Loading