feat: machine-token auth flow for external runners (BGSTM#296) - #305
Merged
Conversation
- 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
…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
marked this pull request as ready for review
May 6, 2026 21:36
This was referenced May 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_atg6h7i8j9k0l1addsrunner_tokenstable; cleanly reversibleAuth layer
security.py:generate_runner_token(),generate_token_salt(),hash_runner_token(plaintext, salt)— stdlib only (secrets,hashlib)dependencies.py:get_current_runner_tokenresolvesAuthorization: ****** →RunnerToken; rejects missing/malformed headers (401), non-runner tokens (401), unknown hash (401), revoked tokens (401); stampslast_used_at` on successdependencies.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)POST/auth/runner-tokensRunnerTokenIssueResponseGET/auth/runner-tokens?include_revoked=trueto show revoked; never exposes plaintextDELETE/auth/runner-tokens/{id}revoked_at; 404 if unknown, 409 if already revokedAll three require admin JWT. Issue and revoke write
auth.runner_token.issue/auth.runner_token.revokeaudit entries.Schemas
RunnerTokenCreate— validatesscopesagainst allowlist{external_results:write, external_results:read}; rejects unknowns with 422RunnerTokenResponse— no plaintext fieldRunnerTokenIssueResponse— extends response with one-timetokenfieldTests
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_scopeaccept/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.pyissues short-lived JWTs to humans via email/password. Runners need:external_results:write,external_results:read) so they cannot read user dataThe 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 flowbackend/app/auth/dependencies.py— existingget_current_userpatternbackend/app/auth/security.py— existing password hashingbackend/app/models/— SQLAlchemy model conventionsbackend/app/crud/— async CRUD conventionsbackend/app/schemas/— Pydantic conventions (note: the merged spec PR addedbackend/app/schemas/external_results.py— read it)backend/alembic/versions/(or wherever migrations live — verify) — match the migration style and namingTasks
1. SQLAlchemy model —
backend/app/models/runner_token.pyCreate a
RunnerTokenmodel. Fields:id: UUID(primary key)hashed_token: str(unique, indexed) — SHA-256 ofbgstm_runner_<...>with a per-token salt; never store plaintextsalt: str— per-token saltlabel: str— human-readable name (e.g., "github-actions-crm-example")scopes: list[str]— PostgresARRAY(String)or JSON column, depending on what the existing schema uses; supported values:external_results:write,external_results:readcreated_by_user_id: UUID(FK tousers.id) — admin who issued the tokencreated_at: datetimelast_used_at: datetime | Nonerevoked_at: datetime | NoneIndexes: unique on
hashed_token. Match relationship/ORM patterns used elsewhere in the codebase.2. CRUD —
backend/app/crud/runner_token.pyAsync helpers (mirror existing
crud/user.pypatterns):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 | Nonerevoke_runner_token(db, token_id: UUID) -> RunnerToken— setsrevoked_at = utcnow()update_last_used(db, token: RunnerToken) -> NoneToken generation:
bgstm_runner_+secrets.token_urlsafe(32). Hashing: SHA-256 with per-token salt (usesecrets.token_bytes(16)for the salt). Use a dedicated helper inbackend/app/auth/security.py(e.g.,hash_runner_token(plaintext: str, salt: str) -> str).3. Pydantic schemas —
backend/app/schemas/runner_token.pyRunnerTokenCreate:label: str,scopes: list[str](default["external_results:write"])RunnerTokenResponse:id,label,scopes,created_at,last_used_at,revoked_at(no token value)RunnerTokenIssueResponse: extendsRunnerTokenResponse+token: str(plaintext, returned only at issuance)Validate that
scopesonly contains values from a known set; reject unknowns with 422.4. Auth dependency — extend
backend/app/auth/dependencies.pyAdd
get_current_runner_token:Behavior:
Authorizationheader →HTTPException(401, "Missing bearer token")bgstm_runner_→HTTPException(401, "Not a runner token")(so user JWTs aren't accidentally accepted here)HTTPException(401, "Invalid runner token")This pull request was created from Copilot chat.