feat(auth): bootstrap token entries carry an optional scope

This commit is contained in:
hh
2026-08-30 18:25:48 +02:00
parent 064d855382
commit e90963a6c3
3 changed files with 54 additions and 20 deletions
+3 -1
View File
@@ -140,7 +140,9 @@ async def _async_main() -> None:
# background task). BOOTSTRAP_TOKENS layers on top so first-run / # background task). BOOTSTRAP_TOKENS layers on top so first-run /
# examples still work without DB writes. # examples still work without DB writes.
token_store = TokenStore( token_store = TokenStore(
db, bootstrap=TokenStore.parse_bootstrap(settings.bootstrap_tokens) db,
bootstrap=TokenStore.parse_bootstrap(settings.bootstrap_tokens),
bootstrap_scopes=TokenStore.parse_bootstrap_scopes(settings.bootstrap_tokens),
) )
async with AsyncExitStack() as stack: async with AsyncExitStack() as stack:
+38 -19
View File
@@ -155,6 +155,7 @@ class TokenStore:
db: Database | None = None, db: Database | None = None,
*, *,
bootstrap: Mapping[str, str] | None = None, bootstrap: Mapping[str, str] | None = None,
bootstrap_scopes: Mapping[str, str] | None = None,
ttl_seconds: float = 30.0, ttl_seconds: float = 30.0,
flush_interval: float = 5.0, flush_interval: float = 5.0,
) -> None: ) -> None:
@@ -173,9 +174,13 @@ class TokenStore:
raise TokenStoreError(msg) raise TokenStoreError(msg)
by_value[value] = name by_value[value] = name
self._bootstrap_by_value: dict[str, str] = by_value self._bootstrap_by_value: dict[str, str] = by_value
self._bootstrap_scopes: dict[str, str] = dict.fromkeys( # Bootstrap entries are ``"*"`` unless the env narrows them
by_value.values(), _BOOTSTRAP_SCOPE # (``name:value:scope``) - a webhook token in a URL should not be
) # an admin token.
self._bootstrap_scopes: dict[str, str] = {
name: (bootstrap_scopes or {}).get(name, _BOOTSTRAP_SCOPE)
for name in by_value.values()
}
self._db = db self._db = db
self._ttl = ttl_seconds self._ttl = ttl_seconds
@@ -191,22 +196,17 @@ class TokenStore:
@staticmethod @staticmethod
def parse_bootstrap(raw: str) -> dict[str, str]: def parse_bootstrap(raw: str) -> dict[str, str]:
"""Parse ``name1:value1,name2:value2`` (the ``BOOTSTRAP_TOKENS`` form).""" """Parse ``name1:value1,name2:value2[:scope]`` (``BOOTSTRAP_TOKENS``)."""
tokens: dict[str, str] = {} return {name: value for name, (value, _scope) in _parse_entries(raw).items()}
for chunk in raw.split(","):
entry = chunk.strip() @staticmethod
if not entry: def parse_bootstrap_scopes(raw: str) -> dict[str, str]:
continue """Scopes given as a third field in ``BOOTSTRAP_TOKENS``; the rest get ``*``."""
name, sep, value = entry.partition(":") return {
if not sep: name: scope
msg = f"token entry missing ':' separator: {entry!r}" for name, (_value, scope) in _parse_entries(raw).items()
raise TokenStoreError(msg) if scope is not None
name, value = name.strip(), value.strip() }
if name in tokens:
msg = f"duplicate token name: {name!r}"
raise TokenStoreError(msg)
tokens[name] = value
return tokens
@classmethod @classmethod
def from_env(cls, raw: str, db: Database | None = None) -> TokenStore: def from_env(cls, raw: str, db: Database | None = None) -> TokenStore:
@@ -378,3 +378,22 @@ __all__ = [
"TokenStoreError", "TokenStoreError",
"hash_token", "hash_token",
] ]
def _parse_entries(raw: str) -> dict[str, tuple[str, str | None]]:
entries: dict[str, tuple[str, str | None]] = {}
for chunk in raw.split(","):
entry = chunk.strip()
if not entry:
continue
name, sep, rest = entry.partition(":")
if not sep:
msg = f"token entry missing ':' separator: {entry!r}"
raise TokenStoreError(msg)
value, sep, scope = rest.partition(":")
name, value, scope = name.strip(), value.strip(), scope.strip()
if name in entries:
msg = f"duplicate token name: {name!r}"
raise TokenStoreError(msg)
entries[name] = (value, scope if sep and scope else None)
return entries
+13
View File
@@ -44,3 +44,16 @@ async def test_missing_everything_is_401(runtime) -> None:
with pytest.raises(HTTPException) as exc: with pytest.raises(HTTPException) as exc:
await require_token(_request(), runtime, scope="api") await require_token(_request(), runtime, scope="api")
assert exc.value.status_code == 401 assert exc.value.status_code == 401
async def test_bootstrap_entry_can_carry_a_scope() -> None:
raw = "admin:secret-a,komodo:hook-value:api"
store = TokenStore(
bootstrap=TokenStore.parse_bootstrap(raw),
bootstrap_scopes=TokenStore.parse_bootstrap_scopes(raw),
)
admin = await store.verify("secret-a")
hook = await store.verify("hook-value")
assert admin is not None and admin.scope == "*"
assert hook is not None and hook.scope == "api" and hook.name == "komodo"
assert not hook.allows("admin") and hook.allows("api")