summaryrefslogtreecommitdiff
path: root/pkg/authz
diff options
context:
space:
mode:
authormo khan <mo@mokhan.ca>2025-07-15 16:37:08 -0600
committermo khan <mo@mokhan.ca>2025-07-17 16:30:22 -0600
commit45df4d0d9b577fecee798d672695fe24ff57fb1b (patch)
tree1b99bf645035b58e0d6db08c7a83521f41f7a75b /pkg/authz
parentf94f79608393d4ab127db63cc41668445ef6b243 (diff)
feat: migrate from Cedar to SpiceDB authorization system
This is a major architectural change that replaces the Cedar policy-based authorization system with SpiceDB's relation-based authorization. Key changes: - Migrate from Rust to Go implementation - Replace Cedar policies with SpiceDB schema and relationships - Switch from envoy `ext_authz` with Cedar to SpiceDB permission checks - Update build system and dependencies for Go ecosystem - Maintain Envoy integration for external authorization This change enables more flexible permission modeling through SpiceDB's Google Zanzibar inspired relation-based system, supporting complex hierarchical permissions that were difficult to express in Cedar. Breaking change: Existing Cedar policies and Rust-based configuration will no longer work and need to be migrated to SpiceDB schema.
Diffstat (limited to 'pkg/authz')
-rw-r--r--pkg/authz/check_service.go158
-rw-r--r--pkg/authz/client.go51
-rw-r--r--pkg/authz/server.go37
-rw-r--r--pkg/authz/server_test.go67
4 files changed, 313 insertions, 0 deletions
diff --git a/pkg/authz/check_service.go b/pkg/authz/check_service.go
new file mode 100644
index 00000000..4df0ebe7
--- /dev/null
+++ b/pkg/authz/check_service.go
@@ -0,0 +1,158 @@
+package authz
+
+import (
+ "context"
+ "net/http"
+ "path/filepath"
+
+ v1 "github.com/authzed/authzed-go/proto/authzed/api/v1"
+ authzed "github.com/authzed/authzed-go/v1"
+ 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"
+ "gitlab.com/gitlab-org/software-supply-chain-security/authorization/authzd.git/pkg/pls"
+ status "google.golang.org/genproto/googleapis/rpc/status"
+ "google.golang.org/grpc/codes"
+)
+
+var StaticAssets []string = []string{".png", ".css", ".html", ".js", ".ico"}
+
+type CheckService struct {
+ client *authzed.Client
+ auth.UnimplementedAuthorizationServer
+}
+
+func NewCheckService(client *authzed.Client) auth.AuthorizationServer {
+ return &CheckService{
+ client: client,
+ }
+}
+
+func (svc *CheckService) Check(ctx context.Context, request *auth.CheckRequest) (*auth.CheckResponse, error) {
+ if svc.isAuthorized(ctx, request) {
+ return svc.OK(ctx), nil
+ }
+ return svc.Denied(ctx), nil
+}
+
+func (svc *CheckService) isAuthorized(ctx context.Context, r *auth.CheckRequest) bool {
+ if !svc.validRequest(ctx, r) {
+ return false
+ }
+ log.WithFields(ctx, svc.fieldsFor(r))
+
+ if svc.isStaticAsset(ctx, r) {
+ return true
+ }
+
+ if x.IsZero(svc.client) {
+ return false
+ }
+
+ response, err := svc.client.CheckPermission(ctx, svc.mapFrom(ctx, r))
+ if err != nil {
+ pls.LogError(ctx, err)
+ return false
+ }
+ return response.Permissionship == v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION
+}
+
+func (svc *CheckService) isStaticAsset(ctx context.Context, r *auth.CheckRequest) bool {
+ if r.Attributes.Request.Http.Method != http.MethodGet {
+ return false
+ }
+
+ extension := filepath.Ext(r.Attributes.Request.Http.Path)
+ return x.Contains(StaticAssets, func(item string) bool {
+ return item == extension
+ })
+}
+
+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)
+}
+
+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{
+ "host": r.Attributes.Request.Http.Host,
+ "id": r.Attributes.Request.Http.Id,
+ "method": r.Attributes.Request.Http.Method,
+ "path": r.Attributes.Request.Http.Path,
+ "protocol": r.Attributes.Request.Http.Protocol,
+ "request_id": r.Attributes.Request.Http.Headers["x-request-id"],
+ "scheme": r.Attributes.Request.Http.Scheme,
+ "subject": r.Attributes.Request.Http.Headers["x-jwt-claim-username"],
+ }
+}
+
+func (svc *CheckService) mapFrom(ctx context.Context, r *auth.CheckRequest) *v1.CheckPermissionRequest {
+ return &v1.CheckPermissionRequest{
+ Resource: svc.resourceFrom(ctx, r),
+ Permission: svc.permissionFrom(ctx, r),
+ Subject: svc.subjectFrom(ctx, r),
+ }
+}
+
+func (svc *CheckService) resourceFrom(ctx context.Context, r *auth.CheckRequest) *v1.ObjectReference {
+ return &v1.ObjectReference{
+ ObjectType: "project",
+ ObjectId: "1",
+ }
+}
+
+func (svc *CheckService) subjectFrom(ctx context.Context, r *auth.CheckRequest) *v1.SubjectReference {
+ //TODO:: username is not ideal but it works for demo purposes
+ username := r.Attributes.Request.Http.Headers["x-jwt-claim-username"]
+ if x.IsZero(username) {
+ username = "public"
+ }
+
+ return &v1.SubjectReference{
+ Object: &v1.ObjectReference{
+ ObjectType: "user",
+ ObjectId: username,
+ },
+ }
+}
+
+func (svc *CheckService) permissionFrom(ctx context.Context, r *auth.CheckRequest) string {
+ return "read"
+}
diff --git a/pkg/authz/client.go b/pkg/authz/client.go
new file mode 100644
index 00000000..eab1fe99
--- /dev/null
+++ b/pkg/authz/client.go
@@ -0,0 +1,51 @@
+package authz
+
+import (
+ "context"
+ "crypto/x509"
+ "net"
+
+ authzed "github.com/authzed/authzed-go/v1"
+ "github.com/authzed/grpcutil"
+ "gitlab.com/gitlab-org/software-supply-chain-security/authorization/authzd.git/pkg/pls"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+ "google.golang.org/grpc/credentials/insecure"
+)
+
+func NewClient(ctx context.Context, host string, token string) (*authzed.Client, error) {
+ tokenOption := grpcutil.WithInsecureBearerToken(token)
+ if isTLS(ctx, host) {
+ tokenOption = grpcutil.WithBearerToken(token)
+ }
+ return authzed.NewClient(
+ host,
+ grpc.WithTransportCredentials(credentialsFor(ctx, host)),
+ tokenOption,
+ )
+}
+func credentialsFor(ctx context.Context, host string) credentials.TransportCredentials {
+ if isTLS(ctx, host) {
+ pool, err := x509.SystemCertPool()
+ if err != nil {
+ pls.LogErrorNow(ctx, err)
+ return insecure.NewCredentials()
+ }
+
+ return credentials.NewClientTLSFromCert(pool, "")
+ }
+
+ return insecure.NewCredentials()
+}
+
+func isTLS(ctx context.Context, host string) bool {
+ if host == "" {
+ return false
+ }
+ _, port, err := net.SplitHostPort(host)
+ if err != nil {
+ pls.LogError(ctx, err)
+ return false
+ }
+ return port == "443"
+}
diff --git a/pkg/authz/server.go b/pkg/authz/server.go
new file mode 100644
index 00000000..0c680a65
--- /dev/null
+++ b/pkg/authz/server.go
@@ -0,0 +1,37 @@
+package authz
+
+import (
+ "context"
+
+ authzed "github.com/authzed/authzed-go/v1"
+ auth "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
+ xcontext "github.com/xlgmokha/x/pkg/context"
+ "github.com/xlgmokha/x/pkg/log"
+ "github.com/xlgmokha/x/pkg/x"
+ "gitlab.com/gitlab-org/software-supply-chain-security/authorization/authzd.git/pkg/pls"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/reflection"
+)
+
+var Connection xcontext.Key[*authzed.Client] = xcontext.Key[*authzed.Client]("authzed_client")
+
+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, NewCheckService(Connection.From(ctx)))
+ 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 00000000..47f22191
--- /dev/null
+++ b/pkg/authz/server_test.go
@@ -0,0 +1,67 @@
+package authz
+
+import (
+ "context"
+ "net"
+ "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"
+)
+
+type HTTPRequest = auth.AttributeContext_HttpRequest
+
+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)
+
+ t.Run("CheckRequest", func(t *testing.T) {
+ tt := []struct {
+ http *HTTPRequest
+ status codes.Code
+ }{
+ {status: codes.OK, http: &HTTPRequest{Method: "GET", Path: "/application.js"}},
+ {status: codes.OK, http: &HTTPRequest{Method: "GET", Path: "/favicon.ico"}},
+ {status: codes.OK, http: &HTTPRequest{Method: "GET", Path: "/favicon.png"}},
+ {status: codes.OK, http: &HTTPRequest{Method: "GET", Path: "/index.html"}},
+ {status: codes.OK, http: &HTTPRequest{Method: "GET", Path: "/application.css"}},
+ }
+
+ 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)
+ })
+ }
+ })
+}