Skip to content

Egress & namespace policy

Translation rules (previous page) decide what the proxy may substitute. Egress and namespace policy decide where it may send the result.

There are two layers:

Layer Scope Surface
Egress allow/block Global, per-domain Policy → Egress
Namespace egress Per-namespace, hierarchical Fleet & Namespaces + Policy → Egress

Both layers are evaluated on every CONNECT. Both fail closed in enforcement mode: a domain that no layer allows is refused before TLS handshake.


Layer 1 — Egress allow/block

The global egress layer controls which destination domains are permitted or blocked across the fleet.

Where in the UI

Policy → Egress.

Egress policy

The page has three regions:

  • A domain (e.g. api.stripe.com) input with Allow and Block buttons.
  • An Allowed table with columns Domain, Reason, Auth, Remove.
  • A Blocked table with the same columns.

Adding an allow

In the UI, type the domain, optionally choose require-auth, click Allow.

By API:

curl -X POST http://localhost:8443/api/policy/egress/allow \
  -H "Content-Type: application/json" \
  -d '{
        "domain":       "api.stripe.com",
        "reason":       "payments",
        "require_auth": true
      }'

Multiple domains, common patterns:

for d in api.stripe.com api.github.com api.bank.eu \
         oauth2.googleapis.com s3.eu-west-1.amazonaws.com; do
  curl -X POST http://localhost:8443/api/policy/egress/allow \
    -d "{\"domain\":\"$d\",\"reason\":\"prod\",\"require_auth\":true}"
done

Blocking

curl -X POST http://localhost:8443/api/policy/egress/block \
  -d '{"domain":"evil-c2.example","reason":"threat-intel"}'

A blocked domain wins over an allowed one with the same exact match.

Removing

curl -X DELETE 'http://localhost:8443/api/policy/egress/allow?domain=api.stripe.com'

Or click Remove in the row.

What's in Reason for?

Just an audit string. It is recorded with every domain allow/block event and is invaluable in incident review later.


Layer 2 — Namespace egress (hierarchical)

Workloads can be grouped into hierarchical namespaces like prod, prod/payments, prod/payments/eu-west. Each namespace can carry its own allowed_domains list.

The fundamental invariant:

Children may only narrow. A child namespace can shrink the parent's allow-list. It can never widen it.

Why this matters

It means an operator who runs a sub-team's namespace cannot escalate beyond the parent's grants. A platform team that gives prod/payments permission for api.stripe.com and api.bank.eu knows that no descendant of prod/payments will ever reach anywhere those two domains are not in its own ancestry.

Where in the UI

Fleet & Namespaces:

Fleet & namespaces

The Namespaces table shows columns Name, Parent, Depth, Allowed domains. The Workloads table shows which workloads belong to which namespace.

Creating a namespace

curl -X POST http://localhost:8443/api/fleet/namespaces \
  -H "Content-Type: application/json" \
  -d '{
        "name":            "prod/payments",
        "description":     "payment-service workloads",
        "allowed_domains": ["api.stripe.com","api.bank.eu"]
      }'

Creating a child that narrows further

curl -X POST http://localhost:8443/api/fleet/namespaces \
  -H "Content-Type: application/json" \
  -d '{
        "name":            "prod/payments/eu-west",
        "description":     "EU-only payments cluster",
        "allowed_domains": ["api.bank.eu"]
      }'

This is valid: every child domain (api.bank.eu) is covered by the parent's allow-list. The child cannot reach api.stripe.com even though the parent can.

Trying to widen

curl -X POST http://localhost:8443/api/fleet/namespaces \
  -d '{
        "name":            "prod/payments/eu-west",
        "allowed_domains": ["api.stripe.com","api.bank.eu","api.foo.com"]
      }'

This is rejected:

{
  "error":     "child_widens_parent",
  "namespace": "prod/payments/eu-west",
  "domain":    "api.foo.com",
  "ancestor":  "prod/payments",
  "message":   "domain api.foo.com is not covered by ancestor prod/payments"
}

Updating a parent that would break a child

If you change a parent's allowed_domains in a way that would make a descendant non-compliant, the update is rejected by default with structured cascade_warnings:

{
  "error":             "cascade_violations",
  "cascade_warnings": [
    {
      "child":             "prod/payments/eu-west",
      "uncovered_domains": ["api.bank.eu"]
    }
  ]
}

You can force the update with ?force=true. Doing so applies the change and returns the warnings; the descendants are now broken until you fix them. Use this only when you intend to also fix the children atomically.

Evaluation at request time

For every CONNECT:

  1. Walk from the workload's namespace leaf to root.
  2. At every namespace that defines allowed_domains, the target host must match.
  3. A blocked_domains entry at any level overrides any allow.
  4. If no namespace in the chain defines allowed_domains, the root's default action applies.
  5. If an intermediate namespace is missing, it is skipped (a warning is logged) and the walk continues.

This means a workload in prod/payments/eu-west calling api.stripe.com is denied because the leaf does not allow it, even though the parent does.


What gets recorded

Event When
policy_allow Domain explicitly added to the allow-list
policy_block Domain explicitly blocked
policy_removed Domain removed from either list
proxy_domain_denied Per-request denial (egress + namespace)
namespace_created Namespace created (including the allowed-domain set)
namespace_updated Namespace modified (records the cascade warnings)

Look for proxy_domain_denied on the Audit page when diagnosing "why was my request blocked."


Common operational patterns

These patterns show typical namespace hierarchies in production.

Per-environment narrowing

/                       (default-deny)
└── prod                ["api.stripe.com","api.github.com","api.bank.eu"]
    ├── prod/payments   ["api.stripe.com","api.bank.eu"]
    └── prod/builds     ["api.github.com","registry.npmjs.org"]

prod/builds references registry.npmjs.org — if it is not in prod, the create call is rejected. Add it to prod first.

Quarantining a namespace

curl -X POST http://localhost:8443/api/fleet/namespaces \
  -d '{
        "name":            "prod/payments",
        "allowed_domains": []
      }'

An empty allowed_domains denies everything. Combined with ?force=true this is the fastest way to cut a namespace off the internet without revoking individual workload tokens.

Investigating a deny

A denied request shows up on the Workstation page in the Suspicious activity table with reason proxy_domain_denied, and on the Audit page as a row tagged proxy_domain_denied. The latter quotes both the target host and the deciding policy layer.