feat(auth): bootstrap token entries carry an optional scope
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user