Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
95e3c5d
docs(phase-7c): add spec + TDD implementation plan for API-Key auth
strausmann May 17, 2026
39e19a0
feat(security): Phase 7c Step 1 — ApiKey model, migration, and reposi…
strausmann May 17, 2026
7605377
feat(security): Phase 7c Step 2 — key generation + bcrypt verify + LR…
strausmann May 17, 2026
033a664
feat(security): Phase 7c Step 3 — require_scope() FastAPI dependency …
strausmann May 17, 2026
2e327a4
feat(security): Phase 7c Step 4 — wire require_scope into all routes
strausmann May 17, 2026
380e612
feat(security): Phase 7c Step 5 — in-memory token-bucket rate limiter
strausmann May 17, 2026
36c9504
feat(security): Phase 7c Step 6 — per-key printer ACL check
strausmann May 17, 2026
0104978
feat(security): Phase 7c Step 7 — audit trail on jobs (api_key_id + s…
strausmann May 17, 2026
ee466be
feat(security): Phase 7c Step 8 — backend CRUD API for /api/admin/api…
strausmann May 17, 2026
46dd6f6
feat(ui): Phase 7c Step 9 — frontend HTMX /admin/api-keys UI
strausmann May 17, 2026
544030d
feat(security): Phase 7c Step 10 — final integration + production-rea…
strausmann May 17, 2026
5282c5f
fix(security): offload bcrypt.checkpw to thread pool (Fix A)
strausmann May 18, 2026
924a9ca
fix(security): scope hierarchy fail-closed + no implicit read (Fixes …
strausmann May 18, 2026
7e0b1e4
fix(api,security): admin_api_keys cleanup — Fixes D+E + GitGuardian (…
strausmann May 18, 2026
1d91cf6
fix(tests): ruff + mypy clean — Fail 1 Python lint/type CI (Round 2)
strausmann May 18, 2026
c4f050f
fix(api): fix retry_after f-string not interpolated in 429 response body
strausmann May 18, 2026
c09c158
feat(api): change key format to lh_pat_ with 16-char prefix
strausmann May 18, 2026
309d6b5
feat(security): add gitleaks + gitguardian custom detector for lh_pat…
strausmann May 18, 2026
3ff8524
chore(security): exclude test trees from GitGuardian scan
strausmann May 18, 2026
17cda07
Merge remote-tracking branch 'origin/main' into feat/phase-7c-api-auth
strausmann May 18, 2026
8b975bf
test(auth): lower-entropy fixture strings for GitGuardian compatibility
strausmann May 18, 2026
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
15 changes: 15 additions & 0 deletions .gitguardian.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
version: 2
detectors:
- name: "Label-Hub PAT"
pattern: 'lh_pat_[A-Za-z0-9_-]{43}'
severity: high
paths-ignore:
# Tests use pseudo-random literals like "lh_testkey_correct_12345" that
# match Shannon-entropy heuristics in GitGuardian's Generic High-Entropy
# detector. They are NOT real secrets — they are intentionally readable
# fixtures so test failures are diagnosable. Excluding the test trees
# rather than tagging every literal with an ignore-comment.
- "backend/tests/**"
- "frontend/internal/**/*_test.go"
- "tests/fixtures/**"
- "docs/**"
12 changes: 12 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
title = "label-printer-hub gitleaks config"

[extend]
# Use default rules plus our custom one
useDefault = true

[[rules]]
id = "labelhub-pat"
description = "Label-Printer-Hub Personal Access Token"
regex = '''lh_pat_[A-Za-z0-9_-]{43}'''
keywords = ["lh_pat_"]
tags = ["key", "label-hub", "pat"]
102 changes: 102 additions & 0 deletions backend/alembic/versions/20260517_phase7c_api_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Phase 7c — api_keys table + audit columns on jobs + bootstrap-admin seed.

Revision ID: 20260517_phase7c_api_keys
Revises: 20260517_phase7b_datetime_tz
Create Date: 2026-05-17
"""

from __future__ import annotations

import json
import secrets

import bcrypt
import sqlalchemy as sa
from alembic import op

revision = "20260517_phase7c_api_keys"
down_revision = "20260517_phase7b_datetime_tz"
branch_labels = None
depends_on = None

_BOOTSTRAP_KEY_NAME = "bootstrap-admin"


def _generate_bootstrap_key() -> tuple[str, str, str]:
body = secrets.token_urlsafe(32)
plaintext = f"lh_pat_{body}"
prefix = plaintext[:16]
hashed = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=12)).decode()
return plaintext, prefix, hashed


def upgrade() -> None:
op.create_table(
"api_keys",
sa.Column("id", sa.Uuid, primary_key=True),
sa.Column("name", sa.String, nullable=False),
sa.Column("key_hash", sa.String, nullable=False),
sa.Column("key_prefix", sa.String, nullable=False),
sa.Column("scopes", sa.JSON, nullable=False),
sa.Column("allowed_printer_ids", sa.JSON, nullable=False),
sa.Column("rate_limit_per_minute", sa.Integer, nullable=False, server_default="60"),
sa.Column("enabled", sa.Boolean, nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_ip", sa.String, nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("notes", sa.String, nullable=True),
)
op.create_index("ix_api_keys_name", "api_keys", ["name"])
op.create_index("ix_api_keys_key_prefix", "api_keys", ["key_prefix"])

with op.batch_alter_table("jobs") as batch_op:
batch_op.add_column(sa.Column("api_key_id", sa.Uuid, nullable=True))
batch_op.add_column(sa.Column("source_ip", sa.String, nullable=True))
op.create_index("ix_jobs_api_key_id", "jobs", ["api_key_id"])

conn = op.get_bind()
count = conn.execute(sa.text("SELECT COUNT(*) FROM api_keys")).scalar()
if count == 0:
from datetime import UTC, datetime
from uuid import uuid4

plaintext, prefix, hashed = _generate_bootstrap_key()
key_id = str(uuid4())
now = datetime.now(UTC).isoformat()
conn.execute(
sa.text(
"INSERT INTO api_keys "
"(id, name, key_hash, key_prefix, scopes, allowed_printer_ids, "
" rate_limit_per_minute, enabled, created_at) "
"VALUES (:id, :name, :hash, :prefix, :scopes, :printers, "
" :rate, :enabled, :now)"
),
{
"id": key_id,
"name": _BOOTSTRAP_KEY_NAME,
"hash": hashed,
"prefix": prefix,
"scopes": json.dumps(["admin"]),
"printers": json.dumps([]),
"rate": 60,
"enabled": 1,
"now": now,
},
)
# Print to stdout (Alembic migration stdout only — NOT the application logger).
# This is the only time the plaintext key is visible; copy it before rotating.
print(
f"[label-printer-hub] BOOTSTRAP API KEY: {plaintext} (prefix: {prefix})"
" — rotate via /api/admin/api-keys after first login"
)


def downgrade() -> None:
op.drop_index("ix_jobs_api_key_id", table_name="jobs")
with op.batch_alter_table("jobs") as batch_op:
batch_op.drop_column("source_ip")
batch_op.drop_column("api_key_id")
op.drop_index("ix_api_keys_key_prefix", table_name="api_keys")
op.drop_index("ix_api_keys_name", table_name="api_keys")
op.drop_table("api_keys")
39 changes: 39 additions & 0 deletions backend/alembic/versions/20260518_phase7c_pat_prefix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Phase 7c — update key_prefix to VARCHAR(16) for lh_pat_ format.

Revision ID: 20260518_phase7c_pat_prefix
Revises: 20260517_phase7c_api_keys
Create Date: 2026-05-18
"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op

revision = "20260518_phase7c_pat_prefix"
down_revision = "20260517_phase7c_api_keys"
branch_labels = None
depends_on = None


def upgrade() -> None:
# SQLite via batch_alter_table supports column type changes.
# For PostgreSQL the String type without length is unlimited, so this
# migration is a no-op in production but makes the intent explicit.
with op.batch_alter_table("api_keys") as batch_op:
batch_op.alter_column(
"key_prefix",
existing_type=sa.String(),
type_=sa.String(16),
nullable=False,
)


def downgrade() -> None:
with op.batch_alter_table("api_keys") as batch_op:
batch_op.alter_column(
"key_prefix",
existing_type=sa.String(16),
type_=sa.String(12),
nullable=False,
)
Loading
Loading