287 lines
9.2 KiB
Go
287 lines
9.2 KiB
Go
package grpcapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"strings"
|
|
|
|
"galaxy/gateway/internal/config"
|
|
"galaxy/gateway/internal/ratelimit"
|
|
"galaxy/gateway/internal/session"
|
|
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
|
|
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/peer"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
const (
|
|
authenticatedGRPCBaseBucketKeyPrefix = "authenticated_grpc/"
|
|
|
|
authenticatedGRPCIPBucketKeySegment = authenticatedGRPCBaseBucketKeyPrefix + "ip="
|
|
authenticatedGRPCSessionBucketKeySegment = authenticatedGRPCBaseBucketKeyPrefix + "session="
|
|
authenticatedGRPCUserBucketKeySegment = authenticatedGRPCBaseBucketKeyPrefix + "user="
|
|
authenticatedGRPCMessageClassBucketKeySegment = authenticatedGRPCBaseBucketKeyPrefix + "message_class="
|
|
|
|
unknownAuthenticatedPeerIP = "unknown"
|
|
|
|
authenticatedRPCExecuteCommand = "ExecuteCommand"
|
|
authenticatedRPCSubscribeEvents = "SubscribeEvents"
|
|
)
|
|
|
|
var (
|
|
// ErrAuthenticatedPolicyDenied reports that the authenticated request was
|
|
// rejected by later edge policy after transport authenticity succeeded.
|
|
ErrAuthenticatedPolicyDenied = errors.New("authenticated request rejected by edge policy")
|
|
|
|
// ErrAuthenticatedPolicyUnavailable reports that authenticated policy could
|
|
// not be evaluated because its backing dependency is unavailable.
|
|
ErrAuthenticatedPolicyUnavailable = errors.New("authenticated request policy is unavailable")
|
|
)
|
|
|
|
// AuthenticatedRequestLimiter applies authenticated gRPC rate-limit policy to
|
|
// one concrete bucket key.
|
|
type AuthenticatedRequestLimiter interface {
|
|
// Reserve evaluates key under policy and reports whether the request may
|
|
// proceed immediately.
|
|
Reserve(key string, policy ratelimit.Policy) ratelimit.Decision
|
|
}
|
|
|
|
// AuthenticatedRequest describes the authenticated request metadata exposed to
|
|
// the edge-policy hook.
|
|
type AuthenticatedRequest struct {
|
|
// RPCMethod identifies the public gRPC method being processed.
|
|
RPCMethod string
|
|
|
|
// PeerIP is the transport peer IP derived from the gRPC connection.
|
|
PeerIP string
|
|
|
|
// MessageClass is the stable rate-limit and policy class. The gateway uses
|
|
// the full message_type literal because the v1 transport does not yet define
|
|
// a coarser authenticated class taxonomy.
|
|
MessageClass string
|
|
|
|
// Envelope contains the verified transport envelope fields used by later
|
|
// edge policy.
|
|
Envelope AuthenticatedRequestEnvelope
|
|
|
|
// Session contains the authenticated identity resolved from SessionCache.
|
|
Session session.Record
|
|
}
|
|
|
|
// AuthenticatedRequestEnvelope describes the verified request envelope fields
|
|
// exposed to the edge-policy hook.
|
|
type AuthenticatedRequestEnvelope struct {
|
|
// ProtocolVersion is the supported transport protocol version literal.
|
|
ProtocolVersion string
|
|
|
|
// DeviceSessionID is the authenticated device-session identifier.
|
|
DeviceSessionID string
|
|
|
|
// MessageType is the verified downstream routing key supplied by the client.
|
|
MessageType string
|
|
|
|
// TimestampMS is the client timestamp that already passed freshness checks.
|
|
TimestampMS int64
|
|
|
|
// RequestID is the authenticated transport request identifier.
|
|
RequestID string
|
|
|
|
// TraceID is the optional client-supplied correlation identifier.
|
|
TraceID string
|
|
}
|
|
|
|
// AuthenticatedRequestPolicy evaluates later authenticated edge policy after
|
|
// transport authenticity and rate-limit checks succeed.
|
|
type AuthenticatedRequestPolicy interface {
|
|
// Evaluate returns nil when the authenticated request may proceed. It should
|
|
// wrap ErrAuthenticatedPolicyDenied for stable reject mapping and
|
|
// ErrAuthenticatedPolicyUnavailable when its backing dependency is
|
|
// temporarily unavailable.
|
|
Evaluate(ctx context.Context, request AuthenticatedRequest) error
|
|
}
|
|
|
|
type authenticatedRateLimitService struct {
|
|
gatewayv1.UnimplementedEdgeGatewayServer
|
|
|
|
delegate gatewayv1.EdgeGatewayServer
|
|
limiter AuthenticatedRequestLimiter
|
|
policy AuthenticatedRequestPolicy
|
|
cfg config.AuthenticatedGRPCAntiAbuseConfig
|
|
}
|
|
|
|
// ExecuteCommand applies authenticated rate limits and edge policy before
|
|
// delegating to the configured service implementation.
|
|
func (s authenticatedRateLimitService) ExecuteCommand(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
|
|
if err := s.applyRateLimitsAndPolicy(ctx, authenticatedRPCExecuteCommand); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.delegate.ExecuteCommand(ctx, req)
|
|
}
|
|
|
|
// SubscribeEvents applies authenticated rate limits and edge policy before
|
|
// delegating to the configured service implementation.
|
|
func (s authenticatedRateLimitService) SubscribeEvents(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
|
|
if err := s.applyRateLimitsAndPolicy(stream.Context(), authenticatedRPCSubscribeEvents); err != nil {
|
|
return err
|
|
}
|
|
|
|
return s.delegate.SubscribeEvents(req, stream)
|
|
}
|
|
|
|
// newAuthenticatedRateLimitService wraps delegate with the authenticated
|
|
// rate-limit and edge-policy gate.
|
|
func newAuthenticatedRateLimitService(delegate gatewayv1.EdgeGatewayServer, limiter AuthenticatedRequestLimiter, policy AuthenticatedRequestPolicy, cfg config.AuthenticatedGRPCAntiAbuseConfig) gatewayv1.EdgeGatewayServer {
|
|
return authenticatedRateLimitService{
|
|
delegate: delegate,
|
|
limiter: limiter,
|
|
policy: policy,
|
|
cfg: cfg,
|
|
}
|
|
}
|
|
|
|
func (s authenticatedRateLimitService) applyRateLimitsAndPolicy(ctx context.Context, rpcMethod string) error {
|
|
request, err := authenticatedRequestFromContext(ctx, rpcMethod)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := s.applyRateLimits(request); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := s.applyPolicy(ctx, request); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s authenticatedRateLimitService) applyRateLimits(request AuthenticatedRequest) error {
|
|
checks := []struct {
|
|
key string
|
|
policy config.AuthenticatedRateLimitConfig
|
|
}{
|
|
{
|
|
key: authenticatedGRPCIPBucketKey(request.PeerIP),
|
|
policy: s.cfg.IP,
|
|
},
|
|
{
|
|
key: authenticatedGRPCSessionBucketKey(request.Envelope.DeviceSessionID),
|
|
policy: s.cfg.Session,
|
|
},
|
|
{
|
|
key: authenticatedGRPCUserBucketKey(request.Session.UserID),
|
|
policy: s.cfg.User,
|
|
},
|
|
{
|
|
key: authenticatedGRPCMessageClassBucketKey(request.MessageClass),
|
|
policy: s.cfg.MessageClass,
|
|
},
|
|
}
|
|
|
|
for _, check := range checks {
|
|
decision := s.limiter.Reserve(check.key, ratelimit.Policy{
|
|
Requests: check.policy.Requests,
|
|
Window: check.policy.Window,
|
|
Burst: check.policy.Burst,
|
|
})
|
|
if !decision.Allowed {
|
|
return status.Error(codes.ResourceExhausted, "authenticated request rate limit exceeded")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s authenticatedRateLimitService) applyPolicy(ctx context.Context, request AuthenticatedRequest) error {
|
|
err := s.policy.Evaluate(ctx, request)
|
|
switch {
|
|
case err == nil:
|
|
return nil
|
|
case errors.Is(err, ErrAuthenticatedPolicyDenied):
|
|
return status.Error(codes.PermissionDenied, "authenticated request rejected by edge policy")
|
|
case errors.Is(err, ErrAuthenticatedPolicyUnavailable):
|
|
return status.Error(codes.Unavailable, "authenticated request policy is unavailable")
|
|
default:
|
|
return status.Error(codes.Internal, "authenticated request policy evaluation failed")
|
|
}
|
|
}
|
|
|
|
func authenticatedRequestFromContext(ctx context.Context, rpcMethod string) (AuthenticatedRequest, error) {
|
|
envelope, ok := parsedEnvelopeFromContext(ctx)
|
|
if !ok {
|
|
return AuthenticatedRequest{}, status.Error(codes.Internal, "authenticated request context is incomplete")
|
|
}
|
|
|
|
record, ok := resolvedSessionFromContext(ctx)
|
|
if !ok {
|
|
return AuthenticatedRequest{}, status.Error(codes.Internal, "authenticated request context is incomplete")
|
|
}
|
|
|
|
return AuthenticatedRequest{
|
|
RPCMethod: rpcMethod,
|
|
PeerIP: peerIPFromContext(ctx),
|
|
MessageClass: authenticatedMessageClass(envelope.MessageType),
|
|
Envelope: AuthenticatedRequestEnvelope{
|
|
ProtocolVersion: envelope.ProtocolVersion,
|
|
DeviceSessionID: envelope.DeviceSessionID,
|
|
MessageType: envelope.MessageType,
|
|
TimestampMS: envelope.TimestampMS,
|
|
RequestID: envelope.RequestID,
|
|
TraceID: envelope.TraceID,
|
|
},
|
|
Session: record,
|
|
}, nil
|
|
}
|
|
|
|
func authenticatedGRPCIPBucketKey(peerIP string) string {
|
|
return authenticatedGRPCIPBucketKeySegment + peerIP
|
|
}
|
|
|
|
func authenticatedGRPCSessionBucketKey(deviceSessionID string) string {
|
|
return authenticatedGRPCSessionBucketKeySegment + deviceSessionID
|
|
}
|
|
|
|
func authenticatedGRPCUserBucketKey(userID string) string {
|
|
return authenticatedGRPCUserBucketKeySegment + userID
|
|
}
|
|
|
|
func authenticatedGRPCMessageClassBucketKey(messageClass string) string {
|
|
return authenticatedGRPCMessageClassBucketKeySegment + messageClass
|
|
}
|
|
|
|
func authenticatedMessageClass(messageType string) string {
|
|
return messageType
|
|
}
|
|
|
|
func peerIPFromContext(ctx context.Context) string {
|
|
peerInfo, ok := peer.FromContext(ctx)
|
|
if !ok || peerInfo.Addr == nil {
|
|
return unknownAuthenticatedPeerIP
|
|
}
|
|
|
|
value := strings.TrimSpace(peerInfo.Addr.String())
|
|
if value == "" {
|
|
return unknownAuthenticatedPeerIP
|
|
}
|
|
|
|
host, _, err := net.SplitHostPort(value)
|
|
if err == nil && host != "" {
|
|
return host
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
type noopAuthenticatedRequestPolicy struct{}
|
|
|
|
func (noopAuthenticatedRequestPolicy) Evaluate(context.Context, AuthenticatedRequest) error {
|
|
return nil
|
|
}
|
|
|
|
var _ gatewayv1.EdgeGatewayServer = authenticatedRateLimitService{}
|