Skip to content

Egress pep - #518

Merged
aojea merged 6 commits into
google:mainfrom
aojea:egress-pep
Sep 26, 2026
Merged

aojea merged 6 commits into
google:mainfrom
aojea:egress-pep

Conversation

@aojea

@aojea aojea commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes: #479

A destination outside the mesh is a service of type egress whose name is
the destination hostname, so a grant reads egress://api.github.com and the
request fact service("egress", "api.github.com"); the grant compiler needs
no new case. Egress names have no .sam.alt projection.

PolicyRole.http narrows one allowed_services entry to HTTP methods and
paths. The control plane compiles it into http_granted_service_* facts with
granted_method and granted_path_* facts and withholds the plain grant for
that entry; BaselineHTTPRules derive the plain grant only for a request
whose method() and path() facts match, so the existing allow policies decide
unchanged and a request without those facts (a tunnel, a bare stream) fails
closed. Grants stay a union: a plain grant from another entry or role is
not narrowed.

PolicyConfig.egress is the admin's list of destinations: name, optional
target_url, the name of the credential the serving node resolves locally,
and the roles or labels that select the serving nodes. EgressAssignmentsResponse
is what a node receives; it travels on a new endpoint so a node predating it
keeps syncing rules unchanged.

Request facts method, path, host and port join the vocabulary; the SDK
Datalog artifact carries the new rules and fact names.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for egress destinations and HTTP narrowing within the SAM mesh. Egress destinations allow nodes to act as policy enforcement points for outbound HTTP calls to services outside the mesh, utilizing credentials stored locally on the serving nodes. HTTP narrowing enables restricting service grants to specific HTTP methods and paths. The changes span the API definitions, control plane distribution endpoints, node proxying and authorization logic, JS and Python SDKs, and documentation. During the review, an improvement opportunity was identified in internal/node/egress.go regarding the safe unwrapping of os.ReadFile errors to prevent formatting issues when errors.Unwrap returns nil.

Comment thread internal/node/egress.go
Comment on lines +135 to +138
data, err := os.ReadFile(filepath.Join(s.secretsDir, name))
if err != nil {
return "", fmt.Errorf("credential %q: %w (put the file in %s)", name, errors.Unwrap(err), s.secretsDir)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using errors.Unwrap(err) directly inside fmt.Errorf with %w is risky because if err is not wrap-compatible (or if it is a custom error), errors.Unwrap will return nil. Passing nil to %w results in formatting issues like %!w(<nil>).\n\nTo safely strip the path from os.PathError while preserving the underlying error for errors.Is checks, use errors.As to extract the *os.PathError and wrap its Err field.

Suggested change
data, err := os.ReadFile(filepath.Join(s.secretsDir, name))
if err != nil {
return "", fmt.Errorf("credential %q: %w (put the file in %s)", name, errors.Unwrap(err), s.secretsDir)
}
data, err := os.ReadFile(filepath.Join(s.secretsDir, name))
if err != nil {
var pathErr *os.PathError
if errors.As(err, &pathErr) {
return "", fmt.Errorf("credential %q: %w (put the file in %s)", name, pathErr.Err, s.secretsDir)
}
return "", fmt.Errorf("credential %q: %w (put the file in %s)", name, err, s.secretsDir)
}

POST /policies accepts the egress section and the http field, validates
them (a served_by entry is a role of the document or a key=value label; a
credential is a name, not a path; an http entry names one of the role's
allowed_services and narrows something) and stores them: http grants as
protojson rows under the role, destinations in their own table.

GET /policies renders one serving rule per served_by entry,
granted_service_exact("egress", name) <- role(r) or <- label(k, v), so a
serving node authorizes local requests with its own credential; the
response carries no new field, so a node predating this keeps syncing.

GET /egress answers a node with the destinations that select it, resolved
from its enrolled role, the roles its identity binds to and its attested
labels. The client reports a 404 as ErrNotFound so a newer node treats an
older control plane as one that assigns nothing.
Authorize injects method() and path() when the node handles a request as
HTTP, and host() and port() on a request for an egress destination, all
taken from the wire; the HTTP narrowing rules join the baseline. The
ingress computes the upstream path before verification, so path() is what
the backend sees and never the routing prefix.

The node pulls its egress assignments with the rest of the control plane
state and reconciles the registry: an EgressService is the HTTP origin for
one destination, forwards to its target_url, and presents the credential
named by the policy, read per request from --secrets-dir so a rotation by
the platform applies at once. The caller's Authorization, X-Sam-* and
X-Forwarded-* headers do not reach the destination. A destination whose
credential is missing is refused and logged; the others are served.
type: egress in sam-node.yaml is refused: a destination is the control
plane's to assign.

/egress/{destination}/{path} on the local API lets an application on the
host reach a destination the node serves. The node evaluates its own
credential with the request facts and the agent the client names; the
target check is satisfied because a node is always allowed to reach
itself. Another member reaches the same destination through
/sam/{peer}/egress/{destination}/{path}, on its own credential.

sam-box: an address the guest dialled without resolving a name is allowed
only by an exact egress entry; a wildcard names a zone and covers no
address.
Both authorizers take the request's method and the path as the backend
sees it, inject them as method() and path() facts, and add the baseline
HTTP rules, so a grant narrowed by PolicyRole.http decides the same way
under a JS or Python provider as under sam-node. The HTTP ingress passes
them from the wire; a stream that carries no HTTP request passes nothing
and a narrowed grant fails closed there.
An admin posts one policy document; a node holding the serving role picks
the destination up with no configuration of its own; an application on
its host reaches it through the local API; a member whose role is narrowed
to GET under a prefix is allowed and refused accordingly through the
serving node; the destination sees the node's credential and nothing of
the callers.
Policy reference: the egress section, the http field, the egress service
type, the evaluation order with the request facts, and the new vocabulary.
Node API: the /egress route. Node config: egress is not declared there;
the request facts available to attenuation. Control plane: GET /egress.
sam-node: --secrets-dir. Authorization concept: why a caller cannot forge a
fact and how a new requirement is a fact or a rule and not a schema change.
A guide walks one request from the admin's document to the destination.

Every Datalog snippet is written in the form biscuit-go parses: the
dialect has no !=, a negation is !(...).

Fixes google#479
@aojea
aojea merged commit 913e868 into google:main Sep 26, 2026
22 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PEP story

1 participant