Skip to content

feat: machine-token auth flow for external runners (BGSTM#296) - #305

Merged
bg-playground merged 3 commits into
mainfrom
copilot/bgstm-296-machine-token-auth-flow
May 6, 2026
Merged

feat: machine-token auth flow for external runners (BGSTM#296)#305
bg-playground merged 3 commits into
mainfrom
copilot/bgstm-296-machine-token-auth-flow

Conversation

Copilot AI commented May 6, 2026

Copy link
Copy Markdown
Contributor

Implements a separate long-lived token type for CI/external test runners — scoped, revocable, and attributable. Distinct from human JWTs: tokens are prefixed bgstm_runner_<32-byte-urlsafe>, stored as salted SHA-256 hashes, and plaintext is returned exactly once at issuance.

New model & migration

  • RunnerToken — fields: id, hashed_token (unique indexed), salt, label, scopes (array), created_by_user_id (FK→users), created_at, last_used_at, revoked_at
  • Alembic migration g6h7i8j9k0l1 adds runner_tokens table; cleanly reversible

Auth layer

  • security.py: generate_runner_token(), generate_token_salt(), hash_runner_token(plaintext, salt) — stdlib only (secrets, hashlib)
  • dependencies.py: get_current_runner_token resolves Authorization: ****** → RunnerToken; rejects missing/malformed headers (401), non-runner tokens (401), unknown hash (401), revoked tokens (401); stamps last_used_at` on success
  • dependencies.py: require_runner_scope(scope) — dependency factory for use by downstream routers (unblocks BGSTM#300):
@router.post("/session", dependencies=[Depends(require_runner_scope("external_results:write"))])

Admin endpoints (/api/v1/auth/runner-tokens)

Method Path Notes
POST /auth/runner-tokens Issues token; returns plaintext once in RunnerTokenIssueResponse
GET /auth/runner-tokens Lists tokens; ?include_revoked=true to show revoked; never exposes plaintext
DELETE /auth/runner-tokens/{id} Sets revoked_at; 404 if unknown, 409 if already revoked

All three require admin JWT. Issue and revoke write auth.runner_token.issue / auth.runner_token.revoke audit entries.

Schemas

  • RunnerTokenCreate — validates scopes against allowlist {external_results:write, external_results:read}; rejects unknowns with 422
  • RunnerTokenResponse — no plaintext field
  • RunnerTokenIssueResponse — extends response with one-time token field

Tests

19 tests covering: admin-only issuance, scope validation, listing/filtering, revoke idempotency (409), dependency edge cases (missing header, wrong prefix, unknown hash, revoked), require_runner_scope accept/reject.

Original prompt

Goal

Implement BGSTM#296 — a machine-token auth flow for external test runners. This is a separate token type from human user JWTs: long-lived, revocable, attributable, scoped.

This is the first implementation slice of the External Results API, building on the merged v1 spec at docs/specs/external_results_v1.md (BGSTM#299). It unblocks #300 (router + session endpoints).

Parent epic: BGSTM#291.

Why a separate token type

Existing backend/app/api/auth.py issues short-lived JWTs to humans via email/password. Runners need:

The token format is fixed by the spec: Authorization: Bearer bgstm_runner_<opaque>.

Repository conventions to match

Inspect these before starting and match style/patterns exactly:

  • backend/app/api/auth.py — existing user JWT flow
  • backend/app/auth/dependencies.py — existing get_current_user pattern
  • backend/app/auth/security.py — existing password hashing
  • Any file under backend/app/models/ — SQLAlchemy model conventions
  • Any file under backend/app/crud/ — async CRUD conventions
  • Any file under backend/app/schemas/ — Pydantic conventions (note: the merged spec PR added backend/app/schemas/external_results.py — read it)
  • Existing Alembic migrations under backend/alembic/versions/ (or wherever migrations live — verify) — match the migration style and naming

Tasks

1. SQLAlchemy model — backend/app/models/runner_token.py

Create a RunnerToken model. Fields:

  • id: UUID (primary key)
  • hashed_token: str (unique, indexed) — SHA-256 of bgstm_runner_<...> with a per-token salt; never store plaintext
  • salt: str — per-token salt
  • label: str — human-readable name (e.g., "github-actions-crm-example")
  • scopes: list[str] — Postgres ARRAY(String) or JSON column, depending on what the existing schema uses; supported values: external_results:write, external_results:read
  • created_by_user_id: UUID (FK to users.id) — admin who issued the token
  • created_at: datetime
  • last_used_at: datetime | None
  • revoked_at: datetime | None

Indexes: unique on hashed_token. Match relationship/ORM patterns used elsewhere in the codebase.

2. CRUD — backend/app/crud/runner_token.py

Async helpers (mirror existing crud/user.py patterns):

  • create_runner_token(db, *, label: str, scopes: list[str], created_by_user_id: UUID) -> tuple[RunnerToken, str] — generates plaintext, hashes it, stores it, returns (model, plaintext_token). Plaintext is returned once, never persisted.
  • get_runner_token_by_hash(db, hashed: str) -> RunnerToken | None
  • revoke_runner_token(db, token_id: UUID) -> RunnerToken — sets revoked_at = utcnow()
  • update_last_used(db, token: RunnerToken) -> None

Token generation: bgstm_runner_ + secrets.token_urlsafe(32). Hashing: SHA-256 with per-token salt (use secrets.token_bytes(16) for the salt). Use a dedicated helper in backend/app/auth/security.py (e.g., hash_runner_token(plaintext: str, salt: str) -> str).

3. Pydantic schemas — backend/app/schemas/runner_token.py

  • RunnerTokenCreate: label: str, scopes: list[str] (default ["external_results:write"])
  • RunnerTokenResponse: id, label, scopes, created_at, last_used_at, revoked_at (no token value)
  • RunnerTokenIssueResponse: extends RunnerTokenResponse + token: str (plaintext, returned only at issuance)

Validate that scopes only contains values from a known set; reject unknowns with 422.

4. Auth dependency — extend backend/app/auth/dependencies.py

Add get_current_runner_token:

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

    Raises 401 if header is missing/malformed, token is unknown, or token is revoked.
    Updates last_used_at on every successful resolution.
    """

Behavior:

  • Missing/malformed Authorization header → HTTPException(401, "Missing bearer token")
  • Bearer doesn't start with bgstm_runner_HTTPException(401, "Not a runner token") (so user JWTs aren't accidentally accepted here)
  • Unknown hash → HTTPException(401, "Invalid runner token")
  • `revoked_at is not No...

This pull request was created from Copilot chat.

- Add RunnerToken SQLAlchemy model with hashed_token, salt, scopes, revoked_at
- Add hash_runner_token/generate_runner_token/generate_token_salt helpers in security.py
- Add CRUD: create_runner_token, get_runner_token_by_hash/id, list_runner_tokens, revoke_runner_token, update_last_used
- Add Pydantic schemas: RunnerTokenCreate, RunnerTokenResponse, RunnerTokenIssueResponse
- Add get_current_runner_token + require_runner_scope dependencies
- Add POST/GET/DELETE /auth/runner-tokens endpoints with admin guard + audit log
- Register RunnerToken in models __init__
- Add Alembic migration g6h7i8j9k0l1
- Add 19 tests covering all acceptance criteria

Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/d9e0801d-b327-4bc7-af36-0acc228ed851

Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
Copilot AI changed the title [WIP] Implement machine-token auth flow for external test runners feat: machine-token auth flow for external runners (BGSTM#296) May 6, 2026
Copilot AI requested a review from bg-playground May 6, 2026 21:23
…f6a7)

The previous down_revision pointed to f5a6b7c8d9e0, which created two
parallel heads in the Alembic tree and caused container startup to fail
with "Multiple head revisions are present". Re-point to the actual
current head (embedding_cache).
@bg-playground
bg-playground marked this pull request as ready for review May 6, 2026 21:36
@bg-playground
bg-playground merged commit ccbd945 into main May 6, 2026
10 checks passed
@bg-playground
bg-playground deleted the copilot/bgstm-296-machine-token-auth-flow branch May 6, 2026 21:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants