-
Notifications
You must be signed in to change notification settings - Fork 11
[HYPERSHELL-297] Filter gateways by cluster_id for managed-cluster pull model #242
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ package gateways | |
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
|
|
||
| "github.com/golang/glog" | ||
| "google.golang.org/grpc" | ||
|
|
@@ -234,6 +235,22 @@ func (h *gatewayGRPCHandler) ListGateways(ctx context.Context, req *pb.ListGatew | |
| Size: int64(size), | ||
| } | ||
|
|
||
| // A managed-cluster control-plane sets cluster_id to its own identity so it | ||
| // only ever lists the gateways it is responsible for provisioning. Filtering | ||
| // server-side keeps foreign gateways off the wire entirely (see WatchGateways, | ||
| // which applies the same cooperative scoping to the event stream). | ||
| // | ||
| // Validate before interpolating into the search DSL: cluster_id is | ||
| // request-supplied, so an unvalidated value (e.g. one containing a quote) | ||
| // could break the filter parse or broaden it. This applies the same field | ||
| // contract the create/update paths enforce on cluster_id. | ||
| if clusterID := req.GetClusterId(); clusterID != "" { | ||
| if err := grpcutil.ValidateStringField("cluster_id", clusterID, false); err != nil { | ||
| return nil, err | ||
| } | ||
| listArgs.Search = fmt.Sprintf("cluster_id = '%s'", clusterID) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| var gateways []Gateway | ||
| paging, svcErr := h.generic.List(ctx, "id", listArgs, &gateways) | ||
| if svcErr != nil { | ||
|
|
@@ -257,6 +274,15 @@ func (h *gatewayGRPCHandler) WatchGateways(req *pb.WatchGatewaysRequest, stream | |
| return status.Error(codes.Unavailable, "event broker not available") | ||
| } | ||
|
|
||
| // clusterFilter, when set, scopes this stream to a single managed cluster. | ||
| // The broker fans EVERY gateway out to EVERY subscriber, so without this a | ||
| // spoke would receive (and could act on) other clusters' gateways. This is | ||
| // cooperative scoping, not an enforced trust boundary: the server does not yet | ||
| // authenticate that the caller owns the claimed cluster_id (no per-caller | ||
| // RBAC), so any control-plane could pass any cluster_id. Enforcement is pending | ||
| // the managed-cluster caller-identity binding (remote gRPC TLS+OIDC dial). | ||
| clusterFilter := req.GetClusterId() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Major] "Security boundary" overstates an unenforced guarantee — Architecture / Clarity The server does not authenticate that the caller owns the claimed |
||
|
|
||
| ctx := stream.Context() | ||
| sub, err := broker.Subscribe(ctx) | ||
| if err != nil { | ||
|
|
@@ -296,7 +322,17 @@ func (h *gatewayGRPCHandler) WatchGateways(req *pb.WatchGatewaysRequest, stream | |
| gateway, svcErr := h.service.GetUnscoped(ctx, evt.SourceID) | ||
| if svcErr != nil { | ||
| glog.Warningf("WatchGateways: failed to load soft-deleted gateway %s: %v", evt.SourceID, svcErr) | ||
| // When a cluster filter is set we cannot attribute an | ||
| // unloadable delete to a cluster, so we must not leak it to a | ||
| // scoped subscriber. Skip it; the spoke's namespace GC still | ||
| // reaps the orphaned namespace. | ||
| if clusterFilter != "" { | ||
| continue | ||
| } | ||
| } else { | ||
| if clusterFilter != "" && gateway.ClusterId != clusterFilter { | ||
| continue | ||
| } | ||
| watchEvent.Gateway = gatewayToProto(gateway) | ||
| } | ||
| } else { | ||
|
|
@@ -305,6 +341,9 @@ func (h *gatewayGRPCHandler) WatchGateways(req *pb.WatchGatewaysRequest, stream | |
| glog.Warningf("WatchGateways: failed to load gateway %s: %v", evt.SourceID, svcErr) | ||
| continue | ||
| } | ||
| if clusterFilter != "" && gateway.ClusterId != clusterFilter { | ||
| continue | ||
| } | ||
| watchEvent.Gateway = gatewayToProto(gateway) | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -127,6 +127,11 @@ message DeleteGatewayRequest { | |
| message ListGatewaysRequest { | ||
| int32 page = 1; | ||
| int32 size = 2; | ||
| // cluster_id, when set, restricts results to gateways assigned to that | ||
| // managed cluster. A control-plane agent sets it to its own cluster identity | ||
| // so it only ever lists its cluster's gateways (managed-cluster pull model). | ||
| // Unset preserves the prior behaviour of listing every gateway. | ||
| optional string cluster_id = 3; | ||
| } | ||
|
|
||
| message ListGatewaysResponse { | ||
|
|
@@ -136,7 +141,13 @@ message ListGatewaysResponse { | |
|
|
||
| message DeleteGatewayResponse {} | ||
|
|
||
| message WatchGatewaysRequest {} | ||
| message WatchGatewaysRequest { | ||
| // cluster_id, when set, restricts the stream to gateways assigned to that | ||
| // managed cluster (see ListGatewaysRequest.cluster_id). Because the event | ||
| // broker fans every gateway out to every subscriber, this filter is the | ||
| // security boundary that keeps a spoke's stream scoped to its own cluster. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This calls the filter "the security boundary that keeps a spoke's stream scoped to its own cluster," but the |
||
| optional string cluster_id = 1; | ||
| } | ||
|
|
||
| message WatchGatewaysResponse { | ||
| EventType type = 1; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Major] Validate
cluster_idbefore interpolation — Security / Input ValidationlistArgs.Search = fmt.Sprintf("cluster_id = '%s'", clusterID)interpolates request-supplied input into a search-DSL string with no validation.security.spec.mdrequires validating all user input and preventing injection. Blast radius is bounded (this internal handler already lists the full fleet when unfiltered, and the DSL scopes to the gateways table), but a value containing a single quote can break the parse or broaden the filter. Validatecluster_id(KSUID /grpcutil.ValidateStringField) before building the filter, as the REST path does by only inlining already-validated IDs invisibilitySearchFilter.