feat(egress): add preserve_auth flag to pass agent Authorization through
test / integration-docker (push) Successful in 11s
test / unit (push) Successful in 37s
lint / lint (push) Successful in 55s
Update Quality Badges / update-badges (push) Failing after 37s
test / integration-firecracker (push) Successful in 4m49s
test / coverage (push) Successful in 14s
test / publish-infra (push) Successful in 1m42s
test / integration-docker (push) Successful in 11s
test / unit (push) Successful in 37s
lint / lint (push) Successful in 55s
Update Quality Badges / update-badges (push) Failing after 37s
test / integration-firecracker (push) Successful in 4m49s
test / coverage (push) Successful in 14s
test / publish-infra (push) Successful in 1m42s
Adds a per-route boolean field preserve_auth (default false) that skips the gateway's Authorization header stripping for that host. Intended for registry endpoints like Docker Hub (registry-1.docker.io) and GHCR (ghcr.io) where the agent must supply its own per-scope bearer token. Threaded through ManifestEgressRoute → EgressRoute → Route, serialized in route_to_yaml_dict, and parsed in parse_routes. The strip at egress_addon.py now checks route.preserve_auth before popping the header. Closes #392
This commit was merged in pull request #453.
This commit is contained in:
@@ -146,6 +146,7 @@ def egress_manifest_routes(
|
|||||||
outbound_detectors=r.OutboundDetectors,
|
outbound_detectors=r.OutboundDetectors,
|
||||||
inbound_detectors=r.InboundDetectors,
|
inbound_detectors=r.InboundDetectors,
|
||||||
outbound_on_match=r.OutboundOnMatch,
|
outbound_on_match=r.OutboundOnMatch,
|
||||||
|
preserve_auth=r.PreserveAuth,
|
||||||
))
|
))
|
||||||
return tuple(out)
|
return tuple(out)
|
||||||
|
|
||||||
|
|||||||
@@ -367,6 +367,9 @@ class EgressAddon:
|
|||||||
|
|
||||||
# Strip agent-set Authorization after DLP scan so smuggled tokens
|
# Strip agent-set Authorization after DLP scan so smuggled tokens
|
||||||
# are caught above; the route may inject gateway-owned auth below.
|
# are caught above; the route may inject gateway-owned auth below.
|
||||||
|
# Routes with preserve_auth=True pass the header through as-is so the
|
||||||
|
# agent's own credentials (e.g. registry bearer tokens) reach the upstream.
|
||||||
|
if route is None or not route.preserve_auth:
|
||||||
flow.request.headers.pop("authorization", None)
|
flow.request.headers.pop("authorization", None)
|
||||||
|
|
||||||
# Build headers mapping for match evaluation
|
# Build headers mapping for match evaluation
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class Route:
|
|||||||
inbound_detectors: tuple[str, ...] | None = None
|
inbound_detectors: tuple[str, ...] | None = None
|
||||||
# "" means unset → DEFAULT_OUTBOUND_ON_MATCH. See OUTBOUND_ON_MATCH_VALUES.
|
# "" means unset → DEFAULT_OUTBOUND_ON_MATCH. See OUTBOUND_ON_MATCH_VALUES.
|
||||||
outbound_on_match: str = ""
|
outbound_on_match: str = ""
|
||||||
|
preserve_auth: bool = False
|
||||||
|
|
||||||
|
|
||||||
LOG_OFF = 0 # no logging
|
LOG_OFF = 0 # no logging
|
||||||
@@ -308,11 +309,18 @@ def _parse_one(idx: int, raw: object) -> Route:
|
|||||||
idx, host, raw_dict,
|
idx, host, raw_dict,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
preserve_auth_raw = raw_dict.get("preserve_auth", False)
|
||||||
|
if preserve_auth_raw is not True and preserve_auth_raw is not False:
|
||||||
|
raise ValueError(
|
||||||
|
f"{label} ({host}): 'preserve_auth' must be a boolean"
|
||||||
|
)
|
||||||
|
preserve_auth: bool = preserve_auth_raw
|
||||||
|
|
||||||
for k in raw_dict:
|
for k in raw_dict:
|
||||||
if k not in ("host", "matches", "auth_scheme", "token_env", "dlp", "git"):
|
if k not in ("host", "matches", "auth_scheme", "token_env", "dlp", "git", "preserve_auth"):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{label} ({host}): unknown key {k!r}; accepted keys "
|
f"{label} ({host}): unknown key {k!r}; accepted keys "
|
||||||
f"are 'host', 'matches', 'auth_scheme', 'token_env', 'dlp', 'git'"
|
f"are 'host', 'matches', 'auth_scheme', 'token_env', 'dlp', 'git', 'preserve_auth'"
|
||||||
)
|
)
|
||||||
|
|
||||||
return Route(
|
return Route(
|
||||||
@@ -324,6 +332,7 @@ def _parse_one(idx: int, raw: object) -> Route:
|
|||||||
outbound_detectors=outbound_detectors,
|
outbound_detectors=outbound_detectors,
|
||||||
inbound_detectors=inbound_detectors,
|
inbound_detectors=inbound_detectors,
|
||||||
outbound_on_match=outbound_on_match,
|
outbound_on_match=outbound_on_match,
|
||||||
|
preserve_auth=preserve_auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -376,6 +385,8 @@ def route_to_yaml_dict(r: Route) -> dict[str, object]:
|
|||||||
dlp["outbound_on_match"] = r.outbound_on_match
|
dlp["outbound_on_match"] = r.outbound_on_match
|
||||||
if dlp:
|
if dlp:
|
||||||
d["dlp"] = dlp
|
d["dlp"] = dlp
|
||||||
|
if r.preserve_auth:
|
||||||
|
d["preserve_auth"] = True
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ class ManifestEgressRoute:
|
|||||||
OutboundDetectors: tuple[str, ...] | None = None
|
OutboundDetectors: tuple[str, ...] | None = None
|
||||||
InboundDetectors: tuple[str, ...] | None = None
|
InboundDetectors: tuple[str, ...] | None = None
|
||||||
OutboundOnMatch: str = ""
|
OutboundOnMatch: str = ""
|
||||||
|
PreserveAuth: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "ManifestEgressRoute":
|
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "ManifestEgressRoute":
|
||||||
@@ -190,11 +191,22 @@ class ManifestEgressRoute:
|
|||||||
f"only 'fetch' is accepted"
|
f"only 'fetch' is accepted"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- preserve_auth ---
|
||||||
|
preserve_auth = False
|
||||||
|
if "preserve_auth" in d:
|
||||||
|
raw_preserve_auth = d.get("preserve_auth")
|
||||||
|
if not isinstance(raw_preserve_auth, bool):
|
||||||
|
raise ManifestError(
|
||||||
|
f"{label} preserve_auth must be a boolean "
|
||||||
|
f"(was {type(raw_preserve_auth).__name__})"
|
||||||
|
)
|
||||||
|
preserve_auth = raw_preserve_auth
|
||||||
|
|
||||||
for k in d:
|
for k in d:
|
||||||
if k not in ("host", "matches", "auth", "role", "dlp", "git"):
|
if k not in ("host", "matches", "auth", "role", "dlp", "git", "preserve_auth"):
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"{label} has unknown key {k!r}; accepted keys are "
|
f"{label} has unknown key {k!r}; accepted keys are "
|
||||||
f"'host', 'matches', 'auth', 'role', 'dlp', 'git'"
|
f"'host', 'matches', 'auth', 'role', 'dlp', 'git', 'preserve_auth'"
|
||||||
)
|
)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
@@ -207,6 +219,7 @@ class ManifestEgressRoute:
|
|||||||
OutboundDetectors=outbound_detectors,
|
OutboundDetectors=outbound_detectors,
|
||||||
InboundDetectors=inbound_detectors,
|
InboundDetectors=inbound_detectors,
|
||||||
OutboundOnMatch=outbound_on_match,
|
OutboundOnMatch=outbound_on_match,
|
||||||
|
PreserveAuth=preserve_auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -413,6 +413,28 @@ class TestAuthInjection(unittest.TestCase):
|
|||||||
assert flow.response is not None
|
assert flow.response is not None
|
||||||
self.assertEqual(403, flow.response.status_code)
|
self.assertEqual(403, flow.response.status_code)
|
||||||
|
|
||||||
|
def test_preserve_auth_passes_agent_token_through(self) -> None:
|
||||||
|
route = Route(host="registry-1.docker.io", preserve_auth=True)
|
||||||
|
addon = _addon(Config(routes=(route,)))
|
||||||
|
flow = _Flow(_Request(
|
||||||
|
host="registry-1.docker.io",
|
||||||
|
headers={"authorization": "Bearer agent-registry-token"},
|
||||||
|
))
|
||||||
|
_run_request(addon, flow)
|
||||||
|
self.assertEqual("Bearer agent-registry-token", flow.request.headers.get("authorization"))
|
||||||
|
self.assertIsNone(flow.response)
|
||||||
|
|
||||||
|
def test_default_route_strips_agent_auth(self) -> None:
|
||||||
|
route = Route(host="registry-1.docker.io")
|
||||||
|
addon = _addon(Config(routes=(route,)))
|
||||||
|
flow = _Flow(_Request(
|
||||||
|
host="registry-1.docker.io",
|
||||||
|
headers={"authorization": "Bearer agent-registry-token"},
|
||||||
|
))
|
||||||
|
_run_request(addon, flow)
|
||||||
|
self.assertIsNone(flow.request.headers.get("authorization"))
|
||||||
|
self.assertIsNone(flow.response)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# git push / fetch over HTTPS
|
# git push / fetch over HTTPS
|
||||||
|
|||||||
@@ -458,6 +458,24 @@ class TestRole(unittest.TestCase):
|
|||||||
_bottle([{"host": "x.example", "role": ["x", 42]}])
|
_bottle([{"host": "x.example", "role": ["x", 42]}])
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreserveAuth(unittest.TestCase):
|
||||||
|
def test_omitted_defaults_false(self):
|
||||||
|
b = _bottle([{"host": "registry-1.docker.io"}])
|
||||||
|
self.assertFalse(b.egress.routes[0].PreserveAuth)
|
||||||
|
|
||||||
|
def test_true_accepted(self):
|
||||||
|
b = _bottle([{"host": "registry-1.docker.io", "preserve_auth": True}])
|
||||||
|
self.assertTrue(b.egress.routes[0].PreserveAuth)
|
||||||
|
|
||||||
|
def test_false_accepted(self):
|
||||||
|
b = _bottle([{"host": "registry-1.docker.io", "preserve_auth": False}])
|
||||||
|
self.assertFalse(b.egress.routes[0].PreserveAuth)
|
||||||
|
|
||||||
|
def test_non_bool_rejected(self):
|
||||||
|
with self.assertRaises(ManifestError):
|
||||||
|
_bottle([{"host": "registry-1.docker.io", "preserve_auth": "yes"}])
|
||||||
|
|
||||||
|
|
||||||
class TestPipelockKeyRejected(unittest.TestCase):
|
class TestPipelockKeyRejected(unittest.TestCase):
|
||||||
def test_pipelock_key_rejected_as_unknown(self):
|
def test_pipelock_key_rejected_as_unknown(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
|
|||||||
Reference in New Issue
Block a user