feat(ateapi): add actor egress policy API - #856
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements the Actor Egress Policy proposal, introducing egress policy bindings and egress credentials along with their CRUD APIs, Redis persistence, validation, and an internal Resolver service. The review feedback highlights critical safety issues in egress_policy.go, specifically potential runtime panics from nil pointer dereferences when cloning policies or accessing Kubernetes secret selectors, as well as a recommendation to improve error handling by not masking internal database errors as permission denied errors.
| if err != nil { | ||
| return nil, fmt.Errorf("while resolving egress policy binding: %w", err) | ||
| } | ||
| response := &egresspolicypb.EffectiveEgressPolicy{Policy: proto.Clone(binding.GetPolicy()).(*ateapipb.EgressPolicySpec)} |
There was a problem hiding this comment.
If binding.GetPolicy() is nil, proto.Clone(nil) will return a nil interface. Attempting to type-assert a nil interface to *ateapipb.EgressPolicySpec will cause a runtime panic. We should check if binding.GetPolicy() is nil before cloning, or handle it safely.
var policy *ateapipb.EgressPolicySpec
if binding.GetPolicy() != nil {
policy = proto.Clone(binding.GetPolicy()).(*ateapipb.EgressPolicySpec)
}
response := &egresspolicypb.EffectiveEgressPolicy{Policy: policy}There was a problem hiding this comment.
Obsolete after the API flattening: EgressPolicySpec was removed, so there is no nullable nested policy message to dereference.
| selector := credential.GetKubernetesSecret() | ||
| secret, err := s.kubeClient.CoreV1().Secrets(selector.GetNamespace()).Get(ctx, selector.GetName(), metav1.GetOptions{}) |
There was a problem hiding this comment.
If selector is nil (for example, if the credential source is not a Kubernetes secret or is unset), calling selector.GetNamespace() will cause a nil pointer dereference panic. A defensive check should be added to ensure selector is non-nil before accessing its fields.
| selector := credential.GetKubernetesSecret() | |
| secret, err := s.kubeClient.CoreV1().Secrets(selector.GetNamespace()).Get(ctx, selector.GetName(), metav1.GetOptions{}) | |
| selector := credential.GetKubernetesSecret() | |
| if selector == nil { | |
| return nil, status.Errorf(codes.FailedPrecondition, "egress credential \"%q\" has no Kubernetes secret selector", name) | |
| } | |
| secret, err := s.kubeClient.CoreV1().Secrets(selector.GetNamespace()).Get(ctx, selector.GetName(), metav1.GetOptions{}) |
There was a problem hiding this comment.
The generated getters are nil-safe, but I added an explicit selector check so malformed (and future unsupported) credential sources clearly fail closed with FailedPrecondition.
| actor, err := s.persistence.GetActor(ctx, actorRef) | ||
| if err != nil || actor.GetMetadata().GetUid() != req.GetActorUid() || actor.GetStatus() != ateapipb.Actor_STATUS_RUNNING { | ||
| return nil, status.Error(codes.PermissionDenied, "actor is not authorized for egress") | ||
| } |
There was a problem hiding this comment.
If s.persistence.GetActor fails due to an internal database error (rather than the actor simply not existing), returning codes.PermissionDenied masks the system failure as a client authorization issue. This can prevent the egress gateway from retrying transient errors. It is better to distinguish between store.ErrNotFound (which should return PermissionDenied or NotFound) and other database errors (which should return codes.Internal or codes.Unavailable).
actor, err := s.persistence.GetActor(ctx, actorRef)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, status.Error(codes.PermissionDenied, "actor is not authorized for egress")
}
return nil, status.Errorf(codes.Internal, "failed to resolve actor: %v", err)
}
if actor.GetMetadata().GetUid() != req.GetActorUid() || actor.GetStatus() != ateapipb.Actor_STATUS_RUNNING {
return nil, status.Error(codes.PermissionDenied, "actor is not authorized for egress")
}There was a problem hiding this comment.
Fixed: operational actor lookup failures now return Unavailable; a missing actor and actor authorization mismatches remain PermissionDenied.
42fe4a0 to
4c4d5fc
Compare
4c4d5fc to
851f291
Compare
| ResourceMetadata metadata = 1; | ||
| } | ||
|
|
||
| // EgressPolicy grants one Actor access to destinations. Rules are ORed; |
There was a problem hiding this comment.
Is Egress atespaced?
There was a problem hiding this comment.
Yes. EgressPolicy is Atespace-scoped: its target Actor must be in the same Atespace, and Credential references resolve within that Atespace. I clarified the proto comment and added a regression assertion for cross-Atespace targets.
1225585 to
279aa81
Compare
| repeated EgressRule rules = 4; | ||
| // Every extension is required. An enforcement point that does not | ||
| // understand one must fail closed. | ||
| repeated google.protobuf.Any extensions = 5; |
There was a problem hiding this comment.
I don't think we should use google.protobuf.Any in our public API. An any field is just a bytes blob and cannot be interpreted by any system that doesn't link against the proto. This means that, for example, a substrate client won't be able to even deserialize / print it unless it links against every single extension proto. I think this will also complicate updates.
There was a problem hiding this comment.
The reasoning can be foudn here: https://docs.google.com/document/d/1s2klDhbF2mB5sslwUyZBdyXD7JqRt8FWONwJ7wgsomE/edit?tab=t.0#heading=h.7hkev5flhg13
TLDR is that policy computation and delivery is left to Substrate because of scale, so in order to have custom policy it has to be colocated on the object. This is the best way to do that in protobuf. Also, this will be an implementation detail in those instances.
I'm of course open to suggestions on better ways.
| oneof target { | ||
| ObjectRef actor = 2; | ||
| } | ||
| google.protobuf.Empty allow_all = 3; |
There was a problem hiding this comment.
Why is this an Empty message?
There was a problem hiding this comment.
It's a signifier, we can also make it a bool if we want.
| const egressGatewayPrincipal = "spiffe://cluster.local/ns/ate-system/sa/atenet-egress" | ||
|
|
||
| var ( | ||
| egressPolicyMutableFields = mutableFields[*ateapipb.EgressPolicy]{ |
There was a problem hiding this comment.
FYI: You can remove this after you rebase on main. See #862
279aa81 to
99bc40e
Compare
99bc40e to
817f5f4
Compare
| } | ||
|
|
||
| message IPBlockMatch { | ||
| repeated string cidrs = 1; |
There was a problem hiding this comment.
Do we need to do credential injection for IPBlackMatch?
There was a problem hiding this comment.
This change seems unrelated to this PR.
| @@ -1322,7 +1320,7 @@ func TestUploadLocalCheckpointDir(t *testing.T) { | |||
| fullRec := func(class string) sandboxAssetsRecord { | |||
There was a problem hiding this comment.
The change to this file seems unrelated to this PR.
Part of #823.
Design: https://docs.google.com/document/d/1s2klDhbF2mB5sslwUyZBdyXD7JqRt8FWONwJ7wgsomE/edit?tab=t.0
Summary
Policy distribution is intentionally on demand for v1; this does not add policy xDS.
Kubernetes Secret read RBAC is a deployment prerequisite and is intentionally not included in this API-only PR.
Testing