the LAN rule moves from nginx into the app, as a setting for keys only
api_keys_from is a typed setting, lan (default, the historical rule) or anywhere, checked in admin_api._auth against the first X-Forwarded-For hop that NPM sets. It governs API keys only: a session is never restricted, the console is a session from anywhere; and the break-glass token is ALWAYS LAN-only, which is not a choice and so is not a setting. LAN ranges are facts about the network and live in code; the docker range is included because NPM, the routines and sibling containers reach the app from arrstack_arr_net. An unparseable address is not LAN - the rule fails closed. With this in place the location /api/admin/ block on NPM host 51 can come out; the app enforces what it enforced, and the toggle never touches NPM. tests/smoke_admin.py 58 -> 76. Proven on a throwaway site: key LAN 200, key WAN 403 naming the setting, session WAN 200, token LAN 200, token WAN 403; set anywhere, key WAN 200 and token WAN still 403; bogus value 422.
This commit is contained in:
+37
-2
@@ -40,6 +40,7 @@ one CAPS dictionary - never by a role comparison here.
|
||||
import hmac
|
||||
import html as _html
|
||||
import inspect
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
|
||||
@@ -60,6 +61,26 @@ router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
docs_router = APIRouter(prefix="/api", tags=["docs"])
|
||||
|
||||
|
||||
# What counts as "inside" for the LAN rule. Facts about the network, so
|
||||
# they live in code; the CHOICE of whether keys are LAN-only is a setting.
|
||||
# The docker range is here because NPM, the routines and any sibling
|
||||
# container reach this app from arrstack_arr_net, and a container on that
|
||||
# network is already inside.
|
||||
LAN_NETS = [ipaddress.ip_network(n) for n in
|
||||
("10.0.0.0/24", "10.0.1.0/24", "127.0.0.0/8", "172.16.0.0/12")]
|
||||
|
||||
|
||||
def client_is_lan(request):
|
||||
"""True if the caller's address is on the LAN. Uses the first
|
||||
X-Forwarded-For hop, which NPM sets; an unparseable or absent address is
|
||||
NOT lan - the rule fails closed."""
|
||||
try:
|
||||
ip = ipaddress.ip_address(auth._client_ip(request) or "")
|
||||
except ValueError:
|
||||
return False
|
||||
return any(ip in n for n in LAN_NETS)
|
||||
|
||||
|
||||
def _person(request):
|
||||
"""Who is calling: a session first, then an API key, then nobody.
|
||||
|
||||
@@ -89,6 +110,10 @@ def _auth(request, token, capability, unit_id=None):
|
||||
"""
|
||||
person = _person(request)
|
||||
if person:
|
||||
if person.get("key_scopes") is not None and not client_is_lan(request) \
|
||||
and identity.get_setting("api_keys_from") == "lan":
|
||||
raise HTTPException(403, "API keys may only be used from the LAN right now "
|
||||
"(site setting api_keys_from)")
|
||||
if not identity.can(person, capability, unit_id):
|
||||
raise HTTPException(403, "your account does not have %s" % capability
|
||||
+ (" for this unit" if unit_id else "")
|
||||
@@ -99,6 +124,10 @@ def _auth(request, token, capability, unit_id=None):
|
||||
raise HTTPException(503, "admin API disabled: sign in, or set ADMIN_TOKEN")
|
||||
if not token or not hmac.compare_digest(token, ADMIN_TOKEN):
|
||||
raise HTTPException(401, "sign in, or send a valid X-Admin-Token")
|
||||
# The shared token is break glass for an operator at the console. It is
|
||||
# never usable from outside, and that is not a setting.
|
||||
if not client_is_lan(request):
|
||||
raise HTTPException(403, "the admin token is LAN-only")
|
||||
return "admin-token"
|
||||
|
||||
|
||||
@@ -380,13 +409,19 @@ def whoami(request: Request, x_admin_token: str = Header(None)):
|
||||
call with a new key."""
|
||||
person = _person(request)
|
||||
if person:
|
||||
if person.get("key_scopes") is not None and not client_is_lan(request) \
|
||||
and identity.get_setting("api_keys_from") == "lan":
|
||||
raise HTTPException(403, "API keys may only be used from the LAN right now "
|
||||
"(site setting api_keys_from)")
|
||||
return {"email": person["email"], "global_role": person.get("global_role"),
|
||||
"capabilities": person["capabilities"],
|
||||
"capabilities": person["capabilities"], "lan": client_is_lan(request),
|
||||
"via": ("key " + person["key_prefix"]) if person.get("key_scopes") is not None else "session"}
|
||||
if not ADMIN_TOKEN:
|
||||
raise HTTPException(503, "admin API disabled: sign in, or set ADMIN_TOKEN")
|
||||
if x_admin_token and hmac.compare_digest(x_admin_token, ADMIN_TOKEN):
|
||||
return {"email": None, "via": "admin-token", "capabilities": ["*"]}
|
||||
if not client_is_lan(request):
|
||||
raise HTTPException(403, "the admin token is LAN-only")
|
||||
return {"email": None, "via": "admin-token", "capabilities": ["*"], "lan": True}
|
||||
raise HTTPException(401, "sign in, send a Bearer key, or a valid X-Admin-Token")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user