Egress pep - #518
Egress pep#518
Conversation
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.
There was a problem hiding this comment.
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.
| 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) | ||
| } |
There was a problem hiding this comment.
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.
| 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
Fixes: #479