From 4beee46dc6c7642316e118a4d3aa51e4b407256e Mon Sep 17 00:00:00 2001 From: mo khan Date: Tue, 20 May 2025 14:28:06 -0600 Subject: feat: add external authorization service (authzd) with JWT authentication - Add new authzd gRPC service implementing Envoy's external authorization API - Integrate JWT authentication filter in Envoy configuration with claim extraction - Update middleware to support both cookie-based and header-based user authentication - Add comprehensive test coverage for authorization service and server - Configure proper service orchestration with authzd, sparkled, and Envoy - Update build system and Docker configuration for multi-service deployment - Add grpcurl tool for gRPC service debugging and testing This enables fine-grained authorization control through Envoy's ext_authz filter while maintaining backward compatibility with existing cookie-based authentication. --- pkg/authz/check_service.go | 114 ++++++++++++++++++++++++++++++++++++++++ pkg/authz/check_service_test.go | 95 +++++++++++++++++++++++++++++++++ pkg/authz/server.go | 31 +++++++++++ pkg/authz/server_test.go | 86 ++++++++++++++++++++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 pkg/authz/check_service.go create mode 100644 pkg/authz/check_service_test.go create mode 100644 pkg/authz/server.go create mode 100644 pkg/authz/server_test.go (limited to 'pkg/authz') diff --git a/pkg/authz/check_service.go b/pkg/authz/check_service.go new file mode 100644 index 0000000..ff4e92a --- /dev/null +++ b/pkg/authz/check_service.go @@ -0,0 +1,114 @@ +package authz + +import ( + "context" + "strings" + + core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + auth "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" + types "github.com/envoyproxy/go-control-plane/envoy/type/v3" + "github.com/xlgmokha/x/pkg/log" + "github.com/xlgmokha/x/pkg/x" + status "google.golang.org/genproto/googleapis/rpc/status" + "google.golang.org/grpc/codes" +) + +type CheckService struct { + auth.UnimplementedAuthorizationServer +} + +func (svc *CheckService) Check(ctx context.Context, request *auth.CheckRequest) (*auth.CheckResponse, error) { + if svc.isAllowed(ctx, request) { + return svc.OK(ctx), nil + } + return svc.Denied(ctx), nil +} + +// TODOD:: Replace with a PaC language +func (svc *CheckService) isPublic(ctx context.Context, r *auth.CheckRequest) bool { + allowed := map[string]bool{ + "GET:/": true, + "GET:/application.js": true, + "GET:/callback": true, + "GET:/dashboard/nav": true, + "GET:/favicon.ico": true, + "GET:/favicon.png": true, + "GET:/health": true, + "GET:/index.html": true, + "GET:/logo.png": true, + "GET:/signout": true, + "GET:/sparkle": true, + "GET:/sparkles": true, + "POST:/sparkles/restore": true, + } + ok, _ := allowed[strings.Join([]string{ + r.Attributes.Request.Http.Method, + r.Attributes.Request.Http.Path, + }, ":")] + return ok +} + +func (svc *CheckService) isAllowed(ctx context.Context, r *auth.CheckRequest) bool { + if !svc.validRequest(ctx, r) { + return false + } + + log.WithFields(ctx, svc.fieldsFor(r)) + return svc.isLoggedIn(ctx, r) || svc.isPublic(ctx, r) +} + +func (svc *CheckService) validRequest(ctx context.Context, r *auth.CheckRequest) bool { + return x.IsPresent(r) && + x.IsPresent(r.Attributes) && + x.IsPresent(r.Attributes.Request) && + x.IsPresent(r.Attributes.Request.Http) +} + +// TODO:: Replace this naive implementation +func (svc *CheckService) isLoggedIn(ctx context.Context, r *auth.CheckRequest) bool { + return x.IsPresent(r.Attributes.Request.Http.Headers["cookie"]) +} + +func (svc *CheckService) OK(ctx context.Context) *auth.CheckResponse { + log.WithFields(ctx, log.Fields{"authorized": true}) + return &auth.CheckResponse{ + Status: &status.Status{ + Code: int32(codes.OK), + }, + HttpResponse: &auth.CheckResponse_OkResponse{ + OkResponse: &auth.OkHttpResponse{ + Headers: []*core.HeaderValueOption{}, + HeadersToRemove: []string{}, + ResponseHeadersToAdd: []*core.HeaderValueOption{}, + }, + }, + } +} + +func (svc *CheckService) Denied(ctx context.Context) *auth.CheckResponse { + log.WithFields(ctx, log.Fields{"authorized": false}) + return &auth.CheckResponse{ + Status: &status.Status{ + Code: int32(codes.PermissionDenied), + }, + HttpResponse: &auth.CheckResponse_DeniedResponse{ + DeniedResponse: &auth.DeniedHttpResponse{ + Status: &types.HttpStatus{ + Code: types.StatusCode_Unauthorized, + }, + Headers: []*core.HeaderValueOption{}, + }, + }, + } +} + +func (svc *CheckService) fieldsFor(r *auth.CheckRequest) log.Fields { + return log.Fields{ + "id": r.Attributes.Request.Http.Id, + "method": r.Attributes.Request.Http.Method, + "path": r.Attributes.Request.Http.Path, + "host": r.Attributes.Request.Http.Host, + "scheme": r.Attributes.Request.Http.Scheme, + "protocol": r.Attributes.Request.Http.Protocol, + } +} diff --git a/pkg/authz/check_service_test.go b/pkg/authz/check_service_test.go new file mode 100644 index 0000000..2f640dc --- /dev/null +++ b/pkg/authz/check_service_test.go @@ -0,0 +1,95 @@ +package authz + +import ( + "strings" + "testing" + + core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + auth "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestCheckService(t *testing.T) { + svc := CheckService{} + + t.Run("allows access", func(t *testing.T) { + idToken := "eyJ0eXAiOiJKV1QiLCJraWQiOiJ0ZDBTbWRKUTRxUGg1cU5Lek0yNjBDWHgyVWgtd2hHLU1Eam9PS1dmdDhFIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwOi8vZ2RrLnRlc3Q6MzAwMCIsInN1YiI6IjEiLCJhdWQiOiJlMzFlMWRhMGI4ZjZiNmUzNWNhNzBjNzkwYjEzYzA0MDZlNDRhY2E2YjJiZjY3ZjU1ZGU3MzU1YTk3OWEyMjRmIiwiZXhwIjoxNzQ3OTM3OTgzLCJpYXQiOjE3NDc5Mzc4NjMsImF1dGhfdGltZSI6MTc0Nzc3NDA2Nywic3ViX2xlZ2FjeSI6IjI0NzRjZjBiMjIxMTY4OGE1NzI5N2FjZTBlMjYwYTE1OTQ0NzU0ZDE2YjFiZDQyYzlkNjc3OWM5MDAzNjc4MDciLCJuYW1lIjoiQWRtaW5pc3RyYXRvciIsIm5pY2tuYW1lIjoicm9vdCIsInByZWZlcnJlZF91c2VybmFtZSI6InJvb3QiLCJlbWFpbCI6ImFkbWluQGV4YW1wbGUuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInByb2ZpbGUiOiJodHRwOi8vZ2RrLnRlc3Q6MzAwMC9yb290IiwicGljdHVyZSI6Imh0dHBzOi8vd3d3LmdyYXZhdGFyLmNvbS9hdmF0YXIvMjU4ZDhkYzkxNmRiOGNlYTJjYWZiNmMzY2QwY2IwMjQ2ZWZlMDYxNDIxZGJkODNlYzNhMzUwNDI4Y2FiZGE0Zj9zPTgwJmQ9aWRlbnRpY29uIiwiZ3JvdXBzX2RpcmVjdCI6WyJnaXRsYWItb3JnIiwidG9vbGJveCIsIm1hc3NfaW5zZXJ0X2dyb3VwX18wXzEwMCIsImN1c3RvbS1yb2xlcy1yb290LWdyb3VwL2FhIiwiY3VzdG9tLXJvbGVzLXJvb3QtZ3JvdXAvYWEvYWFhIiwiZ251d2dldCIsIkNvbW1pdDQ1MSIsImphc2hrZW5hcyIsImZsaWdodGpzIiwidHdpdHRlciIsImdpdGxhYi1leGFtcGxlcyIsImdpdGxhYi1leGFtcGxlcy9zZWN1cml0eSIsIjQxMjcwOCIsImdpdGxhYi1leGFtcGxlcy9kZW1vLWdyb3VwIiwiY3VzdG9tLXJvbGVzLXJvb3QtZ3JvdXAiLCI0MzQwNDQtZ3JvdXAtMSIsIjQzNDA0NC1ncm91cC0yIiwiZ2l0bGFiLW9yZzEiLCJnaXRsYWItb3JnL3NlY3VyZSIsImdpdGxhYi1vcmcvc2VjdXJlL21hbmFnZXJzIiwiZ2l0bGFiLW9yZy9zZWN1cml0eS1wcm9kdWN0cyIsImdpdGxhYi1vcmcvc2VjdXJpdHktcHJvZHVjdHMvYW5hbHl6ZXJzIl19.TjTrGS5FjfPoY0HWkSLvgjogBxB27jX2beosOZAkwXi_gO3q9DTnL0csOgxjoF1UR8baPNfMFBqL1ipLxBdY9vvDxZve-sOhoSptjzLGkCi7uQKeu7r8wNyFWNWhcLwmbinZyENGSZqIDSkHy0lGdo9oj7qqnH6sYqU46jtWACDGSHTFjNNuo1s_P2SZgkaq4c4v4jdlVV_C_Qlvtl7-eaWV1LzTpB4Mz0VWGsRx1pk3-KnS24crhBjxSE383z4Nar4ZhrsrTK-bOj33l6U32gRKNb4g6GxrPXaRQ268n37spQmbQn0aDwmUOABv-aBRy203bCCZca8BJ0XBur8t6w" + accessToken := "f88f60df11e458b594c80b299aee05f8e5805c65c3e779cc6fbc606c4ac36227" + refreshToken := "0847d325d6e4f021c4baaae0ddb425dbd8795807a4751cd2131bec8e8a9aee24" + + cookies := []string{ + "bearer_token=" + accessToken + ";", + "id_token=" + idToken + ";", + "refresh_token=" + refreshToken, + } + + response, err := svc.Check(t.Context(), &auth.CheckRequest{ + Attributes: &auth.AttributeContext{ + Source: &auth.AttributeContext_Peer{ + Address: &core.Address{ + Address: &core.Address_SocketAddress{ + SocketAddress: &core.SocketAddress{ + Address: "127.0.0.1", + PortSpecifier: &core.SocketAddress_PortValue{ + PortValue: 52358, + }, + }, + }, + }, + }, + Destination: &auth.AttributeContext_Peer{ + Address: &core.Address{ + Address: &core.Address_SocketAddress{ + SocketAddress: &core.SocketAddress{ + Address: "127.0.0.1", + PortSpecifier: &core.SocketAddress_PortValue{ + PortValue: 10000, + }, + }, + }, + }, + }, + Request: &auth.AttributeContext_Request{ + Time: ×tamppb.Timestamp{Seconds: 1747937928, Nanos: 476481000}, + Http: &auth.AttributeContext_HttpRequest{ + Id: "1248474133684962828", + Method: "GET", + Headers: map[string]string{ + ":authority": "localhost:10000", + ":method": "GET", + ":path": "/health", + ":scheme": "http", + "accept": "*/*", + "accept-encoding": "gzip, deflate, br, zstd", + "accept-language": "en-US,en;q=0.9", + "cache-control": "max-age=0", + "content-length": "64", + "content-type": "application/json", + "cookie": strings.Join(cookies, "; "), + "origin": "http://localhost:10000", + "referer": "http://localhost:10000/dashboard", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "Linux", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "x-forwarded-proto": "http", + "x-request-id": "7e064610-9e19-4a38-8354-0de0b5fbd7c6", + }, + Path: "/health", + Host: "localhost:10000", + Scheme: "http", + Protocol: "HTTP/1.1", + }, + }, + MetadataContext: &core.Metadata{}, + RouteMetadataContext: &core.Metadata{}, + }, + }) + + require.NoError(t, err) + assert.NotNil(t, response.GetOkResponse()) + }) +} diff --git a/pkg/authz/server.go b/pkg/authz/server.go new file mode 100644 index 0000000..49bcd3d --- /dev/null +++ b/pkg/authz/server.go @@ -0,0 +1,31 @@ +package authz + +import ( + "context" + + auth "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" + "github.com/xlgmokha/x/pkg/log" + "github.com/xlgmokha/x/pkg/x" + "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/pls" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" +) + +type Server struct { + *grpc.Server +} + +func New(ctx context.Context, options ...grpc.ServerOption) *Server { + logger := log.From(ctx) + server := grpc.NewServer(x.Prepend( + options, + grpc.UnaryInterceptor(pls.LogGRPC(logger)), + grpc.StreamInterceptor(pls.LogGRPCStream(logger)), + )...) + auth.RegisterAuthorizationServer(server, &CheckService{}) + reflection.Register(server) + + return &Server{ + Server: server, + } +} diff --git a/pkg/authz/server_test.go b/pkg/authz/server_test.go new file mode 100644 index 0000000..6fa4eee --- /dev/null +++ b/pkg/authz/server_test.go @@ -0,0 +1,86 @@ +package authz + +import ( + "context" + "net" + "strings" + "testing" + + auth "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" +) + +func TestServer(t *testing.T) { + socket := bufconn.Listen(1024 * 1024) + srv := New(t.Context()) + + defer srv.GracefulStop() + go func() { + require.NoError(t, srv.Serve(socket)) + }() + + connection, err := grpc.DialContext( + t.Context(), + "bufnet", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return socket.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer connection.Close() + + client := auth.NewAuthorizationClient(connection) + idToken := "eyJ0eXAiOiJKV1QiLCJraWQiOiJ0ZDBTbWRKUTRxUGg1cU5Lek0yNjBDWHgyVWgtd2hHLU1Eam9PS1dmdDhFIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwOi8vZ2RrLnRlc3Q6MzAwMCIsInN1YiI6IjEiLCJhdWQiOiJlMzFlMWRhMGI4ZjZiNmUzNWNhNzBjNzkwYjEzYzA0MDZlNDRhY2E2YjJiZjY3ZjU1ZGU3MzU1YTk3OWEyMjRmIiwiZXhwIjoxNzQ3OTM3OTgzLCJpYXQiOjE3NDc5Mzc4NjMsImF1dGhfdGltZSI6MTc0Nzc3NDA2Nywic3ViX2xlZ2FjeSI6IjI0NzRjZjBiMjIxMTY4OGE1NzI5N2FjZTBlMjYwYTE1OTQ0NzU0ZDE2YjFiZDQyYzlkNjc3OWM5MDAzNjc4MDciLCJuYW1lIjoiQWRtaW5pc3RyYXRvciIsIm5pY2tuYW1lIjoicm9vdCIsInByZWZlcnJlZF91c2VybmFtZSI6InJvb3QiLCJlbWFpbCI6ImFkbWluQGV4YW1wbGUuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInByb2ZpbGUiOiJodHRwOi8vZ2RrLnRlc3Q6MzAwMC9yb290IiwicGljdHVyZSI6Imh0dHBzOi8vd3d3LmdyYXZhdGFyLmNvbS9hdmF0YXIvMjU4ZDhkYzkxNmRiOGNlYTJjYWZiNmMzY2QwY2IwMjQ2ZWZlMDYxNDIxZGJkODNlYzNhMzUwNDI4Y2FiZGE0Zj9zPTgwJmQ9aWRlbnRpY29uIiwiZ3JvdXBzX2RpcmVjdCI6WyJnaXRsYWItb3JnIiwidG9vbGJveCIsIm1hc3NfaW5zZXJ0X2dyb3VwX18wXzEwMCIsImN1c3RvbS1yb2xlcy1yb290LWdyb3VwL2FhIiwiY3VzdG9tLXJvbGVzLXJvb3QtZ3JvdXAvYWEvYWFhIiwiZ251d2dldCIsIkNvbW1pdDQ1MSIsImphc2hrZW5hcyIsImZsaWdodGpzIiwidHdpdHRlciIsImdpdGxhYi1leGFtcGxlcyIsImdpdGxhYi1leGFtcGxlcy9zZWN1cml0eSIsIjQxMjcwOCIsImdpdGxhYi1leGFtcGxlcy9kZW1vLWdyb3VwIiwiY3VzdG9tLXJvbGVzLXJvb3QtZ3JvdXAiLCI0MzQwNDQtZ3JvdXAtMSIsIjQzNDA0NC1ncm91cC0yIiwiZ2l0bGFiLW9yZzEiLCJnaXRsYWItb3JnL3NlY3VyZSIsImdpdGxhYi1vcmcvc2VjdXJlL21hbmFnZXJzIiwiZ2l0bGFiLW9yZy9zZWN1cml0eS1wcm9kdWN0cyIsImdpdGxhYi1vcmcvc2VjdXJpdHktcHJvZHVjdHMvYW5hbHl6ZXJzIl19.TjTrGS5FjfPoY0HWkSLvgjogBxB27jX2beosOZAkwXi_gO3q9DTnL0csOgxjoF1UR8baPNfMFBqL1ipLxBdY9vvDxZve-sOhoSptjzLGkCi7uQKeu7r8wNyFWNWhcLwmbinZyENGSZqIDSkHy0lGdo9oj7qqnH6sYqU46jtWACDGSHTFjNNuo1s_P2SZgkaq4c4v4jdlVV_C_Qlvtl7-eaWV1LzTpB4Mz0VWGsRx1pk3-KnS24crhBjxSE383z4Nar4ZhrsrTK-bOj33l6U32gRKNb4g6GxrPXaRQ268n37spQmbQn0aDwmUOABv-aBRy203bCCZca8BJ0XBur8t6w" + accessToken := "f88f60df11e458b594c80b299aee05f8e5805c65c3e779cc6fbc606c4ac36227" + refreshToken := "0847d325d6e4f021c4baaae0ddb425dbd8795807a4751cd2131bec8e8a9aee24" + + cookies := []string{ + "bearer_token=" + accessToken + ";", + "id_token=" + idToken + ";", + "refresh_token=" + refreshToken, + } + loggedInHeaders := map[string]string{"cookie": strings.Join(cookies, "; ")} + + t.Run("CheckRequest", func(t *testing.T) { + tt := []struct { + http *auth.AttributeContext_HttpRequest + status codes.Code + }{ + {status: codes.OK, http: &auth.AttributeContext_HttpRequest{Method: "GET", Path: "/"}}, + {status: codes.OK, http: &auth.AttributeContext_HttpRequest{Method: "GET", Path: "/application.js"}}, + {status: codes.OK, http: &auth.AttributeContext_HttpRequest{Method: "GET", Path: "/callback"}}, + {status: codes.OK, http: &auth.AttributeContext_HttpRequest{Method: "GET", Path: "/dashboard", Headers: loggedInHeaders}}, + {status: codes.OK, http: &auth.AttributeContext_HttpRequest{Method: "GET", Path: "/dashboard/nav"}}, + {status: codes.OK, http: &auth.AttributeContext_HttpRequest{Method: "GET", Path: "/favicon.ico"}}, + {status: codes.OK, http: &auth.AttributeContext_HttpRequest{Method: "GET", Path: "/favicon.png"}}, + {status: codes.OK, http: &auth.AttributeContext_HttpRequest{Method: "GET", Path: "/health"}}, + {status: codes.OK, http: &auth.AttributeContext_HttpRequest{Method: "GET", Path: "/index.html"}}, + {status: codes.OK, http: &auth.AttributeContext_HttpRequest{Method: "GET", Path: "/logo.png"}}, + {status: codes.OK, http: &auth.AttributeContext_HttpRequest{Method: "GET", Path: "/signout"}}, + {status: codes.OK, http: &auth.AttributeContext_HttpRequest{Method: "GET", Path: "/sparkles"}}, + {status: codes.OK, http: &auth.AttributeContext_HttpRequest{Method: "POST", Path: "/sparkles/restore"}}, + {status: codes.PermissionDenied, http: &auth.AttributeContext_HttpRequest{Method: "GET", Path: "/dashboard"}}, + {status: codes.PermissionDenied, http: &auth.AttributeContext_HttpRequest{Method: "POST", Path: "/sparkles"}}, + } + + for _, example := range tt { + t.Run(example.http.Path, func(t *testing.T) { + response, err := client.Check(t.Context(), &auth.CheckRequest{ + Attributes: &auth.AttributeContext{ + Request: &auth.AttributeContext_Request{ + Http: example.http, + }, + }, + }) + require.NoError(t, err) + assert.Equal(t, int32(example.status), response.Status.Code) + }) + } + }) +} -- cgit v1.2.3