docs/craft/features/external-apps/egress-proxy-action-policy-enforcement.md
How the egress proxy reads the admin-configured action policies (shipped by the
built-in slice; see action-policies.md) and turns an arbitrary outbound request from a
Craft sandbox into a decision: forward, hold for approval, or block.
This plan defines the read + match + resolve contract between the proxy and
the policy system. The proxy's runtime/delivery model (in-process in
api_server vs. a separate sidecar) and the full approval UX/event flow are
deliberately left open — this plan keeps the contract independent of both.
external_app_policy) but nothing consumes it. There is
no enforcement and no code path from "outbound request" to "policy decision".curl, or
anything else — the proxy must be able to:
external_app, if any),EndpointSpec),ALWAYS | ASK | DENY),action_policy_views). The proxy needs the same resolution. Two copies of
a security decision will drift — they must share one implementation.ASK (fail-open for
built-in off-catalog calls).onyx/external_apps/providers/actions.py
(EndpointSpec, MatchRule = RestRoute | GraphQLOp). Per-provider catalogs
in providers/{slack,linear,google_calendar}.py.external_app_policy rows via
onyx/db/external_app.py::get_policies.external_app.upstream_url_patterns
(the existing app-match layer — reuse it).upstream_url_patterns) already
exists in the egress path; this work adds the second level — action match
within the app, using the catalog MatchRules. RestRoute keys off HTTP
method + path regex; GraphQLOp keys off the parsed request body's operation
type + root field.onyx/external_apps), exposing a generic function the proxy
calls. Keep provider specifics out of the proxy; the proxy stays a thin
policy consumer.resolve_policy(app, action_id) -> EndpointPolicy (and a request-level
decide(app, request) -> Decision) that both action_policy_views and the
proxy call. This is the linchpin — the admin preview and live enforcement must
never disagree.MatchRule is already serialisable. Because the rules are a typed
discriminated union, the proxy can consume them directly whether they come
from code (built-in) or, later, from a stored row (custom apps re-add match
as a serialised MatchRule). One matcher serves both.action-policies.md specifies built-in
off-catalog → DENY (fail closed). The current implementation resolves
everything unconfigured to ASK. The proxy workstream must choose: (a) keep
uniform ASK, or (b) reintroduce a per-app fail-closed default for built-in
off-catalog requests. This is a security posture call — surface it to the
owner; do not let it default by omission.(app, request descriptor) -> Decision { policy, matched_action? }. If
the proxy is in-process it's a direct call; if it's a sidecar it's the body of
a thin RPC/HTTP endpoint. Don't couple the resolution logic to either.ASK
logic out of action_policy_views into a single resolve_policy in the
onyx/external_apps (or onyx/db/external_app.py) domain layer; have the
API view call it. No behaviour change — this just creates the seam the proxy
shares.external_app and a normalised request descriptor (method, path, optional
parsed-body fields), tests the app's catalog MatchRules and returns the
matched action_id (or None). REST vs. GraphQL handled by the union.decide(app, request):
app-match (existing) → action-match (step 2) → resolve_policy (step 1) →
Decision. On no action match, apply the resolved default-posture
(per the open decision above). Return enough metadata (matched action's
normalised_name) for the approval prompt.decide. Behind the transport-agnostic contract.
ALWAYS → forward; DENY → block (structured 403); ASK → hand off to the
approval flow (event emission / hold mechanism owned by the approval
workstream — out of scope here).../../../../plans/builtin-app-endpoint-policy-rules.md
already sketches a "proxy read contract" against the old schema
(default_for_unknown, external_app_endpoint_policy, composite PK). Either
supersede that section with this plan or update it to the current shape so
there is one read contract.resolve_policy: stored override wins; unset resolves to the agreed default.action_id; an off-catalog
request returns None.decide: end-to-end app→action→policy for one ALWAYS, one ASK, one
DENY, and one off-catalog request.ALWAYS action forwards and a DENY action is blocked
through the actual egress path. Defer until the runtime model is chosen.