From 68d988c63826c1a18c07d50608e4da1a811c24dc Mon Sep 17 00:00:00 2001 From: Niranjani Vivek Date: Tue, 4 Nov 2025 16:29:48 +0000 Subject: [PATCH] Implements the frontend logic for gNSI Pathz Signed-off-by: Niranjani Vivek --- Makefile | 4 + gnmi_server/gnsi_pathz.go | 272 +++++ gnmi_server/gnsi_pathz_test.go | 1195 +++++++++++++++++++++ gnmi_server/server.go | 83 +- go.mod | 3 +- go.sum | 4 + pathz_authorizer/pathz_authorizer.go | 533 +++++++++ pathz_authorizer/pathz_authorizer_test.go | 503 +++++++++ testdata/gnsi/pathz-version.json | 1 + testdata/gnsi/pathz_policy.pb.txt | 17 + 10 files changed, 2607 insertions(+), 8 deletions(-) create mode 100644 gnmi_server/gnsi_pathz.go create mode 100644 gnmi_server/gnsi_pathz_test.go create mode 100644 pathz_authorizer/pathz_authorizer.go create mode 100644 pathz_authorizer/pathz_authorizer_test.go create mode 100644 testdata/gnsi/pathz-version.json create mode 100644 testdata/gnsi/pathz_policy.pb.txt diff --git a/Makefile b/Makefile index bdda389eb..af5849c57 100644 --- a/Makefile +++ b/Makefile @@ -192,6 +192,8 @@ $(BUILD_GNOI_YANG_PROTO_DIR)/.proto_sonic_done: $(SONIC_YANGS) $(GNOI_YANG): $(BUILD_GNOI_YANG_PROTO_DIR)/.proto_api_done $(BUILD_GNOI_YANG_PROTO_DIR)/.proto_sonic_done @echo "+++++ Compiling PROTOBUF files; +++++" + # Remove the toolchain directive added by newer Go versions + sed -i '/^toolchain/d' go.mod $(GO) install github.com/gogo/protobuf/protoc-gen-gofast @mkdir -p $(@D) $(foreach file, $(wildcard $(BUILD_GNOI_YANG_PROTO_DIR)/*/*.proto), PATH=$(PROTOC_PATH) protoc -I$(@D) $(PROTOC_OPTS_WITHOUT_VENDOR) --gofast_out=plugins=grpc,Mgoogle/protobuf/struct.proto=github.com/gogo/protobuf/types:$(BUILD_GNOI_YANG_PROTO_DIR) $(file);) @@ -223,6 +225,7 @@ check_gotest: $(DBCONFG) $(ENVFILE) sudo CGO_LDFLAGS="$(CGO_LDFLAGS)" CGO_CXXFLAGS="$(CGO_CXXFLAGS)" $(GO) test -race -coverprofile=coverage-telemetry.txt -covermode=atomic -mod=vendor -v github.com/sonic-net/sonic-gnmi/telemetry sudo CGO_LDFLAGS="$(CGO_LDFLAGS)" CGO_CXXFLAGS="$(CGO_CXXFLAGS)" $(GO) test -race -coverprofile=coverage-config.txt -covermode=atomic -v github.com/sonic-net/sonic-gnmi/sonic_db_config sudo CGO_LDFLAGS="$(CGO_LDFLAGS)" CGO_CXXFLAGS="$(CGO_CXXFLAGS)" $(TESTENV) $(GO) test -race -timeout 20m -coverprofile=coverage-gnmi.txt -covermode=atomic -mod=vendor $(BLD_FLAGS) -v github.com/sonic-net/sonic-gnmi/gnmi_server -coverpkg ../... + sudo CGO_LDFLAGS="$(CGO_LDFLAGS)" CGO_CXXFLAGS="$(CGO_CXXFLAGS)" $(TESTENV) $(GO) test -race -timeout 20m -coverprofile=coverage-pathz_authorizer.txt -covermode=atomic -mod=vendor $(BLD_FLAGS) -v github.com/sonic-net/sonic-gnmi/pathz_authorizer -coverpkg ../... ifneq ($(ENABLE_DIALOUT_VALUE),0) sudo CGO_LDFLAGS="$(CGO_LDFLAGS)" CGO_CXXFLAGS="$(CGO_CXXFLAGS)" $(TESTENV) $(GO) test -coverprofile=coverage-dialout.txt -covermode=atomic -mod=vendor $(BLD_FLAGS) -v github.com/sonic-net/sonic-gnmi/dialout/dialout_client endif @@ -256,6 +259,7 @@ INTEGRATION_BASIC_PKGS := \ # Integration test packages that need special environment INTEGRATION_ENV_PKGS := \ github.com/sonic-net/sonic-gnmi/gnmi_server \ + github.com/sonic-net/sonic-gnmi/pathz_authorizer \ github.com/sonic-net/sonic-gnmi/transl_utils \ github.com/sonic-net/sonic-gnmi/gnoi_client/system diff --git a/gnmi_server/gnsi_pathz.go b/gnmi_server/gnsi_pathz.go new file mode 100644 index 000000000..5b52a0306 --- /dev/null +++ b/gnmi_server/gnsi_pathz.go @@ -0,0 +1,272 @@ +package gnmi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "sync" + + "github.com/sonic-net/sonic-gnmi/pathz_authorizer" + + log "github.com/golang/glog" + "github.com/golang/protobuf/proto" + "github.com/openconfig/gnsi/pathz" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var ( + pathzMu sync.Mutex +) + +const ( + pathzTbl string = "PATHZ_POLICY|" + pathzVersionFld string = "pathz_version" + pathzCreatedOnFld string = "pathz_created_on" + pathzPolicyActive pathzInstance = "ACTIVE" + // support for sandbox not yet implemented + pathzPolicySandbox pathzInstance = "SANDBOX" +) + +type pathzInstance string +type PathzMetadata struct { + PathzVersion string `json:"pathz_version"` + PathzCreatedOn string `json:"pathz_created_on"` +} + +type GNSIPathzServer struct { + *Server + pathzProcessor pathz_authorizer.GnmiAuthzProcessorInterface + pathzMetadata *PathzMetadata + pathzMetadataCopy *PathzMetadata + policyCopy *pathz.AuthorizationPolicy + policyUpdated bool + pathzV1Policy string + pathzV1PolicyBackup string + pathz.UnimplementedPathzServer +} + +func NewPathzMetadata() *PathzMetadata { + return &PathzMetadata{ + PathzVersion: "unknown", + PathzCreatedOn: "0", + } +} + +func (srv *GNSIPathzServer) Probe(context.Context, *pathz.ProbeRequest) (*pathz.ProbeResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Probe not implemented") +} + +func (srv *GNSIPathzServer) Get(context.Context, *pathz.GetRequest) (*pathz.GetResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Get not implemented") +} +func NewGNSIPathzServer(srv *Server) *GNSIPathzServer { + ret := &GNSIPathzServer{ + Server: srv, + pathzProcessor: &pathz_authorizer.GnmiAuthzProcessor{}, + pathzMetadata: NewPathzMetadata(), + pathzV1Policy: srv.config.PathzPolicyFile, + pathzV1PolicyBackup: srv.config.PathzPolicyFile + ".backup", + } + if err := ret.loadPathzFreshness(srv.config.PathzMetaFile); err != nil { + log.V(0).Info(err) + } + ret.writePathzMetadataToDB(pathzPolicyActive) + if srv.config.PathzPolicy { + if err := ret.pathzProcessor.UpdatePolicyFromFile(ret.pathzV1Policy); err != nil { + log.V(0).Infof("Failed to load gNMI pathz file %s: %v", ret.pathzV1Policy, err) + } + } + return ret +} + +func (srv *GNSIPathzServer) savePathzFileFreshess(path string) error { + log.V(2).Infof("Saving pathz metadata to file: %s", path) + buf := new(bytes.Buffer) + enc := json.NewEncoder(buf) + if err := enc.Encode(*srv.pathzMetadata); err != nil { + log.V(0).Info(err) + return err + } + return attemptWrite(path, buf.Bytes(), 0o644) +} + +func (srv *GNSIPathzServer) loadPathzFreshness(path string) error { + bytes, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(bytes, srv.pathzMetadata) +} + +func (srv *GNSIPathzServer) savePathzPolicyToFile(p *pathz.AuthorizationPolicy) (string, error) { + content := proto.MarshalTextString(p) + log.V(3).Infof("Saving pathz policy to file: %s", srv.pathzV1Policy) + return content, attemptWrite(srv.pathzV1Policy, []byte(content), 0o644) +} + +func (srv *GNSIPathzServer) verifyPathzFile(c string) error { + content, err := os.ReadFile(srv.pathzV1Policy) + if err != nil { + return err + } + if c != string(content) { + return fmt.Errorf("Pathz file %s contains error.", srv.pathzV1Policy) + } + return nil +} + +func (srv *GNSIPathzServer) writePathzMetadataToDB(instance pathzInstance) error { + id := string(instance) + log.V(2).Infof("Writing pathz metadata to DB: %s Version: %s CreatedOn: %s", id, srv.pathzMetadata.PathzVersion, srv.pathzMetadata.PathzCreatedOn) + if err := writeCredentialsMetadataToDB(pathzTbl+id, "", pathzVersionFld, srv.pathzMetadata.PathzVersion); err != nil { + return err + } + return writeCredentialsMetadataToDB(pathzTbl+id, "", pathzCreatedOnFld, srv.pathzMetadata.PathzCreatedOn) +} + +func (srv *GNSIPathzServer) updatePolicy(p *pathz.AuthorizationPolicy) error { + log.V(2).Info("Updating gNMI pathz policy") + log.V(3).Infof("Policy: %v", p.String()) + c, err := srv.savePathzPolicyToFile(p) + if err != nil { + return err + } + if err := srv.verifyPathzFile(c); err != nil { + log.V(0).Infof("Failed to verify gNMI pathz policy: %v", err) + return err + } + err = srv.pathzProcessor.UpdatePolicyFromProto(p) + if err != nil { + log.V(0).Infof("Failed to update gNMI pathz policy: %v", err) + } + return err +} + +func (srv *GNSIPathzServer) createCheckpoint() error { + log.V(2).Info("Creating gNMI pathz policy checkpoint") + srv.policyCopy = srv.pathzProcessor.GetPolicy() + srv.policyUpdated = false + srv.pathzMetadataCopy = srv.pathzMetadata + return copyFile(srv.pathzV1Policy, srv.pathzV1PolicyBackup) +} + +func (srv *GNSIPathzServer) revertPolicy() error { + log.V(2).Info("Reverting gNMI pathz policy") + if srv.policyUpdated { + srv.policyUpdated = false + if err := srv.pathzProcessor.UpdatePolicyFromProto(srv.policyCopy); err != nil { + log.V(0).Infof("Failed to revert gNMI pathz policy: %v", err) + os.Remove(srv.pathzV1PolicyBackup) + return err + } + } + srv.pathzMetadata = srv.pathzMetadataCopy + return os.Rename(srv.pathzV1PolicyBackup, srv.pathzV1Policy) +} + +func (srv *GNSIPathzServer) commitChanges() error { + log.V(2).Info("Committing gNMI pathz policy changes") + if err := srv.writePathzMetadataToDB(pathzPolicyActive); err != nil { + return err + } + return srv.savePathzFileFreshess(srv.config.PathzMetaFile) +} + +// Rotate implements the gNSI.pathz.Rotate RPC. +func (srv *GNSIPathzServer) Rotate(stream pathz.Pathz_RotateServer) error { + log.V(2).Info("gNSI pathz Rotate RPC") + ctx := stream.Context() + ctx, err := authenticateFunc(srv.config, ctx, "gnoi", false) + if err != nil { + return err + } + // Concurrent Pathz RPCs are not allowed. + if !pathzMu.TryLock() { + log.V(0).Infoln("Concurrent Pathz RPCs are not allowed") + return status.Errorf(codes.Aborted, "Concurrent Pathz RPCs are not allowed") + } + defer pathzMu.Unlock() + if err := fileCheck(srv.pathzV1Policy); err != nil { + log.V(0).Infof("Error in reading file %s: %v", srv.pathzV1Policy, err) + return status.Errorf(codes.NotFound, "Error in reading file %s: %v", srv.pathzV1Policy, err) + } + if err := srv.createCheckpoint(); err != nil { + log.V(0).Infof("Error in creating checkpoint: %v", err) + return status.Errorf(codes.Aborted, "Error in creating checkpoint: %v", err) + } + for { + req, err := stream.Recv() + log.V(3).Infof("Received a Rotate request message: %v", req.String()) + if err == io.EOF { + log.V(0).Infoln("Received EOF instead of a UploadRequest/Finalize request! Reverting to last good state") + // Connection closed without Finalize message. Revert all changes made until now. + if err := srv.revertPolicy(); err != nil { + return status.Errorf(codes.Aborted, "Error in reverting policy: %v", err) + } + return status.Errorf(codes.Aborted, "No Finalize message") + } + if err != nil { + log.V(0).Infof("Reverting to last good state Received error: %v", err) + // Connection closed without Finalize message. Revert all changes made until now. + srv.revertPolicy() + return status.Errorf(codes.Aborted, err.Error()) + } + if endReq := req.GetFinalizeRotation(); endReq != nil { + // This is the last message. All changes are final. + log.V(2).Infof("Received a Finalize request message: %v", endReq) + if !srv.policyUpdated { + log.V(0).Infoln("Received finalize message without successful rotation") + srv.revertPolicy() + return status.Errorf(codes.Aborted, "Received finalize message without successful rotation") + } + if err := srv.commitChanges(); err != nil { + // Revert won't be called if the final commit fails. + return status.Errorf(codes.Aborted, "Final policy commit fails: %v", err) + } + os.Remove(srv.pathzV1PolicyBackup) + return nil + } + resp, err := srv.processRotateRequest(req) + if err != nil { + log.V(0).Infof("Reverting to last good state; While processing a rotate request got error: %v", err) + // Connection closed without Finalize message. Revert all changes made until now. + srv.revertPolicy() + return err + } + if err := stream.Send(resp); err != nil { + log.V(0).Infof("Reverting to last good state; While sending a confirmation got error: %v", err) + // Connection closed without Finalize message. Revert all changes made until now. + srv.revertPolicy() + return status.Errorf(codes.Aborted, err.Error()) + } + } +} + +func (srv *GNSIPathzServer) processRotateRequest(req *pathz.RotateRequest) (*pathz.RotateResponse, error) { + policyReq := req.GetUploadRequest() + if policyReq == nil { + return nil, status.Errorf(codes.Aborted, "Unknown request: %v", req) + } + log.V(2).Infof("Received a gNSI.Pathz UploadRequest request message") + if len(policyReq.GetVersion()) == 0 { + return nil, status.Errorf(codes.Aborted, "Pathz policy version cannot be empty") + } + if srv.pathzMetadata.PathzVersion == policyReq.GetVersion() && !req.GetForceOverwrite() { + return nil, status.Errorf(codes.AlreadyExists, "Pathz with version `%v` already exists", policyReq.GetVersion()) + } + srv.pathzMetadata.PathzVersion = policyReq.GetVersion() + srv.pathzMetadata.PathzCreatedOn = strconv.FormatUint(policyReq.GetCreatedOn(), 10) + if err := srv.updatePolicy(policyReq.GetPolicy()); err != nil { + return nil, status.Errorf(codes.Aborted, err.Error()) + } + srv.policyUpdated = true + resp := &pathz.RotateResponse{ + Response: &pathz.RotateResponse_Upload{}, + } + return resp, nil +} diff --git a/gnmi_server/gnsi_pathz_test.go b/gnmi_server/gnsi_pathz_test.go new file mode 100644 index 000000000..e759f079f --- /dev/null +++ b/gnmi_server/gnsi_pathz_test.go @@ -0,0 +1,1195 @@ +package gnmi + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" // Added for net.TCPAddr and net.ParseIP + "net/url" // Added for url.Parse + "os" + "sync" + "testing" + "time" + + "github.com/golang/protobuf/proto" + gnmipb "github.com/openconfig/gnmi/proto/gnmi" + "github.com/openconfig/gnsi/pathz" + testcert "github.com/sonic-net/sonic-gnmi/testdata/tls" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +const ( + // Pathz is a location of the Pathz Policy + pathzTestPolicyFile = "../testdata/gnsi/pathz_policy.pb.txt" + pathzTestMetaFile = "../testdata/gnsi/pathz-version.json" + port = 8081 +) + +var ( + TestPathzPolicyFile string // Global variable to hold policy path + TestPathzMetaFile string // Global variable to hold meta path +) + +func createPathzServer(t *testing.T, portPathz int64) *Server { + t.Helper() + certificate, err := testcert.NewCert() + if err != nil { + t.Fatalf("could not load server key pair: %s", err) + } + tlsCfg := &tls.Config{ + ClientAuth: tls.RequestClientCert, + Certificates: []tls.Certificate{certificate}, + } + + opts := []grpc.ServerOption{grpc.Creds(credentials.NewTLS(tlsCfg))} + cfg := &Config{ + Port: portPathz, + EnableTranslibWrite: true, + UserAuth: AuthTypes{ + "password": true, + "cert": true, + "jwt": true, + }, + ImgDir: "/tmp", + PathzMetaFile: TestPathzMetaFile, + PathzPolicyFile: TestPathzPolicyFile, + PathzPolicy: true, + } + s, err := NewServer(cfg, opts, nil) + if err != nil { + t.Fatalf("Failed to create Pathz server: %v", err) + } + return s +} + +var pathzRotationTestCases = []struct { + desc string + f func(ctx context.Context, t *testing.T, sc pathz.PathzClient, s *Server) +}{ + { + desc: "RotateOpenClose", + f: func(ctx context.Context, t *testing.T, sc pathz.PathzClient, s *Server) { + stream, err := sc.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + stream.CloseSend() + if _, err = stream.Recv(); err == nil { + t.Fatal("Expected an error reporting premature closure of the stream.") + } + if status.Code(err) != codes.Aborted { + t.Fatalf("Unexpected error: %v", err) + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyPermit) + }, + }, + { + desc: "RotateStreamRecvError", + f: func(ctx context.Context, t *testing.T, sc pathz.PathzClient, s *Server) { + // 0) Open the streaming RPC. + // We use a context with a short timeout, and then cancel it later to trigger the error. + shortCtx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + stream, err := sc.Rotate(shortCtx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + + // 1) Send a valid policy upload request to move the server past the auth check + // and into the main `for` loop. This also creates a backup file. + err = stream.Send(&pathz.RotateRequest{ + RotateRequest: &pathz.RotateRequest_UploadRequest{ + UploadRequest: &pathz.UploadRequest{ + Version: generatePathzVersion(), + CreatedOn: generatePathzCreatedOn(), + Policy: &pathz.AuthorizationPolicy{ + Rules: []*pathz.AuthorizationRule{ + &pathz.AuthorizationRule{ + Id: "Rule1", + Principal: &pathz.AuthorizationRule_User{User: "User1"}, + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + &gnmipb.PathElem{ + Name: "a", + }, + &gnmipb.PathElem{ + Name: "b", + Key: map[string]string{ + "k1": "v1", + "k2": "v2", + }, + }, + }, + }, + Action: pathz.Action_ACTION_PERMIT, + Mode: pathz.Mode_MODE_READ, + }, + }, + }, + }, + }, + }) + if err != nil { + t.Fatal(err.Error()) + } + // 2) Receive the confirmation response. + resp, err := stream.Recv() + if err != nil { + t.Fatal(err.Error()) + } + if cfm := resp.GetUpload(); cfm == nil { + t.Fatal("Did not receive expected UploadResponse response") + } + + // 3) Cancel the client-side context. + cancel() + + // 4) Attempt to receive the next message. This should fail with a non-EOF error. + _, err = stream.Recv() + + // We expect a non-nil error. + if err == nil { + t.Fatal("Expected an error (e.g., context canceled) from stream.Recv()") + } + + // The server wraps the `stream.Recv()` error and returns it with codes.Aborted. + // The original error inside the server will be a gRPC/context error (e.g., codes.Canceled or DeadlineExceeded). + if status.Code(err) != codes.Canceled { + t.Fatalf("Expected codes.Canceled error from client due to context cancellation, got: %v", status.Code(err)) + } + }, + }, + { + desc: "RotateStreamSendError", + f: func(ctx context.Context, t *testing.T, sc pathz.PathzClient, s *Server) { + // 0) Create a temporary, separate connection just for this test, as we must close it prematurely. + tlsConfig := &tls.Config{InsecureSkipVerify: true} + cred := &loginCreds{Username: testUsername, Password: testPassword} + opts := []grpc.DialOption{ + grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), + grpc.WithPerRPCCredentials(cred), + } + targetAddr := fmt.Sprintf("127.0.0.1:%d", port) + + conn, err := grpc.Dial(targetAddr, opts...) + if err != nil { + t.Fatalf("Dialing to %s failed: %v", targetAddr, err) + } + // NOTE: Defer conn.Close() is NOT used as we close it manually. + + tempClient := pathz.NewPathzClient(conn) + // Open the streaming RPC. + stream, err := tempClient.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + conn.Close() + t.Fatal(err.Error()) + } + + // 1) Send a valid policy upload request. This will cause the server to process it + // and attempt to send the response (`stream.Send(resp)`). + err = stream.Send(&pathz.RotateRequest{ + RotateRequest: &pathz.RotateRequest_UploadRequest{ + UploadRequest: &pathz.UploadRequest{ + Version: generatePathzVersion(), + CreatedOn: generatePathzCreatedOn(), + Policy: &pathz.AuthorizationPolicy{ + Rules: []*pathz.AuthorizationRule{ + &pathz.AuthorizationRule{ + Id: "Rule1", + Principal: &pathz.AuthorizationRule_User{User: "User1"}, + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + &gnmipb.PathElem{ + Name: "a", + }, + &gnmipb.PathElem{ + Name: "b", + Key: map[string]string{ + "k1": "v1", + "k2": "v2", + }, + }, + }, + }, + Action: pathz.Action_ACTION_PERMIT, + Mode: pathz.Mode_MODE_READ, + }, + }, + }, + }, + }, + }) + if err != nil { + conn.Close() + t.Fatal(err.Error()) + } + + // 2) Immediately close the underlying client connection. + // This guarantees the server's subsequent stream.Send(resp) will fail with a transport error, + // triggering the desired coverage block. + if err := conn.Close(); err != nil { + t.Fatalf("Failed to close connection: %v", err) + } + + // 3) Attempt to receive confirmation. This call will fail due to the closed connection. + // The failure of the server's Send operation (unseen directly by the client) + // covers the target lines in the server's Rotate RPC. + if _, err := stream.Recv(); err == nil { + t.Fatal("Expected an error (connection closed) but received a successful response.") + } + }, + }, + { + desc: "RotatePolicyEmptyUploadRequest", + f: func(ctx context.Context, t *testing.T, sc pathz.PathzClient, s *Server) { + // 0) Open the streaming RPC. + stream, err := sc.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + // 1) Generate a rotation request and send it to the switch. + err = stream.Send(&pathz.RotateRequest{ + RotateRequest: &pathz.RotateRequest_UploadRequest{ + UploadRequest: &pathz.UploadRequest{}, + }, + }) + if err != nil { + t.Fatal(err.Error()) + } + // 2) Receive error reporting premature closure of the stream. + if _, err = stream.Recv(); err == nil { + t.Fatal("Expected an error reporting premature closure of the stream.") + } + if status.Code(err) != codes.Aborted { + t.Fatalf("Unexpected error: %v", err) + } + // And sanity check + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyPermit) + }, + }, + { + desc: "RotatePolicyEmptyRequest", + f: func(ctx context.Context, t *testing.T, sc pathz.PathzClient, s *Server) { + stream, err := sc.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + if err = stream.Send(&pathz.RotateRequest{}); err != nil { + t.Fatal(err.Error()) + } + if _, err = stream.Recv(); err == nil { + t.Fatal("Expected an error reporting premature closure of the stream.") + } + if status.Code(err) != codes.Aborted { + t.Fatalf("Unexpected error: %v", err) + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyPermit) + }, + }, + { + desc: "RotatePolicyWrongPolicyProto", + f: func(ctx context.Context, t *testing.T, sc pathz.PathzClient, s *Server) { + stream, err := sc.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + req := &pathz.RotateRequest{ + RotateRequest: &pathz.RotateRequest_UploadRequest{ + UploadRequest: &pathz.UploadRequest{ + Version: generatePathzVersion(), + CreatedOn: generatePathzCreatedOn(), + Policy: &pathz.AuthorizationPolicy{ + Rules: []*pathz.AuthorizationRule{ + &pathz.AuthorizationRule{ + Id: "Rule1", + Principal: &pathz.AuthorizationRule_User{User: "User1"}, + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + &gnmipb.PathElem{ + Name: "a", + }, + &gnmipb.PathElem{ + Name: "b", + Key: map[string]string{ + "k1": "v1", + "k2": "v2", + }, + }, + }, + }, + Action: pathz.Action_ACTION_PERMIT, + Mode: pathz.Mode_MODE_READ, + }, + &pathz.AuthorizationRule{ + Id: "Rule2", + Principal: &pathz.AuthorizationRule_User{User: "User1"}, + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + &gnmipb.PathElem{ + Name: "a", + }, + &gnmipb.PathElem{ + Name: "b", + Key: map[string]string{ + "k1": "v1", + "k3": "v3", + }, + }, + }, + }, + Action: pathz.Action_ACTION_PERMIT, + Mode: pathz.Mode_MODE_READ, + }, + }, + }, + }, + }, + } + if err = stream.Send(req); err != nil { + t.Fatal(err.Error()) + } + if _, err = stream.Recv(); err == nil { + t.Fatal("Expected an error reporting premature closure of the stream.") + } + if status.Code(err) != codes.Aborted { + t.Fatalf("Unexpected error: %v", err) + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyPermit) + }, + }, + { + desc: "RotatePolicyNoVersion", + f: func(ctx context.Context, t *testing.T, sc pathz.PathzClient, s *Server) { + stream, err := sc.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + req := &pathz.RotateRequest{ + RotateRequest: &pathz.RotateRequest_UploadRequest{ + UploadRequest: &pathz.UploadRequest{ + CreatedOn: generatePathzCreatedOn(), + Policy: &pathz.AuthorizationPolicy{ + Rules: []*pathz.AuthorizationRule{ + &pathz.AuthorizationRule{ + Id: "Rule1", + Principal: &pathz.AuthorizationRule_User{User: "User1"}, + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + &gnmipb.PathElem{ + Name: "a", + }, + &gnmipb.PathElem{ + Name: "b", + Key: map[string]string{ + "k1": "v1", + "k2": "v2", + }, + }, + }, + }, + Action: pathz.Action_ACTION_PERMIT, + Mode: pathz.Mode_MODE_READ, + }, + }, + }, + }, + }, + } + if err = stream.Send(req); err != nil { + t.Fatal(err.Error()) + } + if _, err = stream.Recv(); err == nil { + t.Fatal("Expected an error reporting premature closure of the stream.") + } + if status.Code(err) != codes.Aborted { + t.Fatalf("Unexpected error: %v", err) + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyPermit) + }, + }, + { + desc: "RotatePolicySuccess", + f: func(ctx context.Context, t *testing.T, sc pathz.PathzClient, s *Server) { + stream, err := sc.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + policy := &pathz.AuthorizationPolicy{} + if err = proto.UnmarshalText(string(pathzTestPolicyDeny), policy); err != nil { + t.Fatal(err.Error()) + } + if err = stream.Send(&pathz.RotateRequest{ + RotateRequest: &pathz.RotateRequest_UploadRequest{ + UploadRequest: &pathz.UploadRequest{ + Version: generatePathzVersion(), + CreatedOn: generatePathzCreatedOn(), + Policy: policy, + }, + }, + }); err != nil { + t.Fatal(err.Error()) + } + if resp, err := stream.Recv(); err != nil || resp.GetUpload() == nil { + t.Fatalf("Did not receive expected UploadResponse response; err: %v", err) + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyDeny) + if err = stream.Send(&pathz.RotateRequest{RotateRequest: &pathz.RotateRequest_FinalizeRotation{}}); err != nil { + t.Fatal(err.Error()) + } + if _, err = stream.Recv(); err == nil { + t.Fatal("Expected an error") + } + if err != io.EOF { + t.Fatalf("Unexpected error: %v", err) + } + if err := resetPathzPolicyFile(s.config.PathzPolicyFile); err != nil { + t.Errorf("Error when reverting to V1: %v", err) + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyPermit) + }, + }, + { + desc: "RotatePolicyNoFinalize", + f: func(ctx context.Context, t *testing.T, sc pathz.PathzClient, s *Server) { + stream, err := sc.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + policy := &pathz.AuthorizationPolicy{} + if err = proto.UnmarshalText(string(pathzTestPolicyDeny), policy); err != nil { + t.Fatal(err.Error()) + } + req := &pathz.RotateRequest{ + RotateRequest: &pathz.RotateRequest_UploadRequest{ + UploadRequest: &pathz.UploadRequest{ + Version: generatePathzVersion(), + CreatedOn: generatePathzCreatedOn(), + Policy: policy, + }, + }, + } + if err = stream.Send(req); err != nil { + t.Fatal(err.Error()) + } + resp, err := stream.Recv() + if err != nil { + t.Fatal(err.Error()) + } + if cfm := resp.GetUpload(); cfm == nil { + t.Fatal("Did not receive expected UploadResponse response") + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyDeny) + stream.CloseSend() + _, err = stream.Recv() + if err == nil { + t.Fatal("Expected an error reporting premature closure of the stream.") + } + if status.Code(err) != codes.Aborted { + t.Fatalf("Unexpected error: %v", err) + } + if err := resetPathzPolicyFile(s.config.PathzPolicyFile); err != nil { + t.Errorf("Error when reverting to V1: %v", err) + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyPermit) + }, + }, + { + desc: "FinalizeNoRotate", + f: func(ctx context.Context, t *testing.T, sc pathz.PathzClient, s *Server) { + stream, err := sc.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + if err := stream.Send(&pathz.RotateRequest{ + RotateRequest: &pathz.RotateRequest_FinalizeRotation{}, + }); err != nil { + t.Fatal(err.Error()) + } + if _, err := stream.Recv(); status.Code(err) != codes.Aborted { + t.Fatalf("unexpected error; want Arborted, got: %v", err) + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyPermit) + }, + }, + { + desc: "RotateTheSamePolicyTwice", + f: func(ctx context.Context, t *testing.T, sc pathz.PathzClient, s *Server) { + stream, err := sc.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + policy := &pathz.AuthorizationPolicy{} + if err = proto.UnmarshalText(string(pathzTestPolicyDeny), policy); err != nil { + t.Fatal(err.Error()) + } + req := &pathz.RotateRequest{ + RotateRequest: &pathz.RotateRequest_UploadRequest{ + UploadRequest: &pathz.UploadRequest{ + Version: generatePathzVersion(), + CreatedOn: generatePathzCreatedOn(), + Policy: policy, + }, + }, + } + if err = stream.Send(req); err != nil { + t.Fatal(err.Error()) + } + resp, err := stream.Recv() + if err != nil { + t.Fatal(err.Error()) + } + if cfm := resp.GetUpload(); cfm == nil { + t.Fatal("Did not receive expected UploadResponse response") + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyDeny) + if err = stream.Send(&pathz.RotateRequest{RotateRequest: &pathz.RotateRequest_FinalizeRotation{}}); err != nil { + t.Fatal(err.Error()) + } + if _, err = stream.Recv(); err == nil { + t.Fatal("Expected an error") + } + if err != io.EOF { + t.Fatalf("Unexpected error: %v", err) + } + // Send the same pathz policy to the switch. + stream, err = sc.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + if err = stream.Send(req); err != nil { + t.Fatal(err.Error()) + } + if _, err = stream.Recv(); err == nil { + t.Fatal("Expected an error") + } + if status.Code(err) != codes.AlreadyExists { + t.Fatalf("Unexpected error: %v", err) + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyDeny) + if err := resetPathzPolicyFile(s.config.PathzPolicyFile); err != nil { + t.Errorf("Error when reverting to V1: %v", err) + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyPermit) + }, + }, + { + desc: "RotateTheSamePolicyTwiceWithForceOverwrite", + f: func(ctx context.Context, t *testing.T, sc pathz.PathzClient, s *Server) { + stream, err := sc.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + policy := &pathz.AuthorizationPolicy{} + if err = proto.UnmarshalText(string(pathzTestPolicyDeny), policy); err != nil { + t.Fatal(err.Error()) + } + req := &pathz.RotateRequest{ + RotateRequest: &pathz.RotateRequest_UploadRequest{ + UploadRequest: &pathz.UploadRequest{ + Version: generatePathzVersion(), + CreatedOn: generatePathzCreatedOn(), + Policy: policy, + }, + }, + ForceOverwrite: true, + } + if err = stream.Send(req); err != nil { + t.Fatal(err.Error()) + } + resp, err := stream.Recv() + if err != nil { + t.Fatal(err.Error()) + } + if cfm := resp.GetUpload(); cfm == nil { + t.Fatal("Did not receive expected UploadResponse response") + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyDeny) + if err = stream.Send(&pathz.RotateRequest{RotateRequest: &pathz.RotateRequest_FinalizeRotation{}}); err != nil { + t.Fatal(err.Error()) + } + if _, err = stream.Recv(); err == nil { + t.Fatal("Expected an error") + } + if err != io.EOF { + t.Fatalf("Unexpected error: %v", err) + } + // Send the same pathz policy to the switch with force overwrite. + stream, err = sc.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + if err = stream.Send(req); err != nil { + t.Fatal(err.Error()) + } + resp, err = stream.Recv() + if err != nil { + t.Fatal(err.Error()) + } + if cfm := resp.GetUpload(); cfm == nil { + t.Fatal("Did not receive expected UploadResponse response") + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyDeny) + if err = stream.Send(&pathz.RotateRequest{RotateRequest: &pathz.RotateRequest_FinalizeRotation{}}); err != nil { + t.Fatal(err.Error()) + } + if _, err = stream.Recv(); err == nil { + t.Fatal("Expected an error") + } + if err != io.EOF { + t.Fatalf("Unexpected error: %v", err) + } + if err := resetPathzPolicyFile(s.config.PathzPolicyFile); err != nil { + t.Errorf("Error when reverting to V1: %v", err) + } + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyPermit) + }, + }, + { + desc: "ParallelRotationCalls", + f: func(ctx context.Context, t *testing.T, sc pathz.PathzClient, s *Server) { + stream, err := sc.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + policy := &pathz.AuthorizationPolicy{} + if err = proto.UnmarshalText(string(pathzTestPolicyDeny), policy); err != nil { + t.Fatal(err.Error()) + } + req := &pathz.RotateRequest{ + RotateRequest: &pathz.RotateRequest_UploadRequest{ + UploadRequest: &pathz.UploadRequest{ + Version: generatePathzVersion(), + CreatedOn: generatePathzCreatedOn(), + Policy: policy, + }, + }, + } + if err = stream.Send(req); err != nil { + t.Fatal(err.Error()) + } + resp, err := stream.Recv() + if err != nil { + t.Fatal(err.Error()) + } + if cfm := resp.GetUpload(); cfm == nil { + t.Fatal("Did not receive expected UploadResponse response") + } + expectPolicyMatch(t, s.config.PathzPolicyFile, pathzTestPolicyDeny) + // Attempt to send the same pathz policy to the switch. + stream2, err := sc.Rotate(ctx, grpc.EmptyCallOption{}) + if err != nil { + t.Fatal(err.Error()) + } + stream2.Send(req) + if _, err = stream2.Recv(); err == nil { + t.Fatal("Expected an error") + } + if status.Code(err) != codes.Aborted { + t.Fatalf("Unexpected error: %v", err) + } + // Finalize the operation. + if err = stream.Send(&pathz.RotateRequest{RotateRequest: &pathz.RotateRequest_FinalizeRotation{}}); err != nil { + t.Fatal(err.Error()) + } + if _, err = stream.Recv(); err == nil { + t.Fatal("Expected an error") + } + if err != io.EOF { + t.Fatalf("Unexpected error: %v", err) + } + expectPolicyMatch(t, s.config.PathzPolicyFile, pathzTestPolicyDeny) + if err := resetPathzPolicyFile(s.config.PathzPolicyFile); err != nil { + t.Errorf("Error when reverting to V1: %v", err) + } + expectPolicyMatch(t, s.config.PathzPolicyFile, pathzTestPolicyPermit) + }, + }, +} + +// TestPathzRotation tests implementation of pathz rotate service. +func TestGnsiPathzRotation(t *testing.T) { + // Set the configuration paths globally. + TestPathzPolicyFile = pathzTestPolicyFile + TestPathzMetaFile = pathzTestMetaFile + + const testPort = 8081 + s := createPathzServer(t, testPort) + + defer os.Remove(pathzTestPolicyFile) + go runServer(t, s) + defer s.Stop() + + // Create a gNSI.pathz client and connect it to the gNSI.pathz server. + tlsConfig := &tls.Config{InsecureSkipVerify: true} + + // Use dummy credentials for the client + cred := &loginCreds{Username: testUsername, Password: testPassword} + + // Attach both TLS transport and the PerRPC BasicAuth credentials + opts := []grpc.DialOption{ + grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), + grpc.WithPerRPCCredentials(cred), + } + + targetAddr := fmt.Sprintf("127.0.0.1:%d", port) + orig := authenticateFunc + defer func() { authenticateFunc = orig }() + authenticateFunc = func(config *Config, ctx context.Context, target string, writeAccess bool) (context.Context, error) { + return ctx, nil + } + var mu sync.Mutex + for _, tc := range pathzRotationTestCases { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + t.Run(tc.desc, func(t *testing.T) { + mu.Lock() + defer mu.Unlock() + conn, err := grpc.Dial(targetAddr, opts...) + if err != nil { + cancel() + t.Fatalf("Dialing to %s failed: %v", targetAddr, err) + } + defer conn.Close() + sc := pathz.NewPathzClient(conn) + tc.f(ctx, t, sc, s) + if err := resetPathzPolicyFile(s.config.PathzPolicyFile); err != nil { + t.Errorf("Error when reverting to V1: %v", err) + } + // And sanity check + expectPolicyMatch(t, pathzTestPolicyFile, pathzTestPolicyPermit) + + }) + cancel() + } + s.gnsiPathz.savePathzFileFreshess(s.config.PathzMetaFile) +} + +// TestGnsiPathzRotateUnauthenticated tests implementation of gnsi.pathz Rotate Unsuthenticated error. +func TestGnsiPathzRotateUnauthenticated(t *testing.T) { + const testPort = 8083 // Use a different port to avoid conflict + s := createPathzServer(t, testPort) + go runServer(t, s) + defer s.Stop() + + // Create gNSI.pathz client + tlsConfig := &tls.Config{InsecureSkipVerify: true} + cred := &loginCreds{Username: testUsername, Password: testPassword} + opts := []grpc.DialOption{ + grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), + grpc.WithPerRPCCredentials(cred), + } + targetAddr := fmt.Sprintf("127.0.0.1:%d", testPort) + conn, err := grpc.Dial(targetAddr, opts...) + if err != nil { + t.Fatalf("Dialing to %s failed: %v", targetAddr, err) + } + defer conn.Close() + noCredsClient := pathz.NewPathzClient(conn) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + // 1) Open the streaming RPC using the client *without* credentials. + stream, err := noCredsClient.Rotate(ctx, grpc.EmptyCallOption{}) + + // Check if the server immediately rejected the connection due to missing credentials. + // If the error is not nil, we check the status code. + if err != nil { + if status.Code(err) != codes.Unauthenticated { + t.Fatalf("Expected Unauthenticated error on stream creation, got: %v (code: %v)", err, status.Code(err)) + } + return // Authentication failed as expected. + } + + // 2) If the stream successfully opened, the server's authentication + // will fail upon the first `Recv()`. We close the send stream to get the final error. + stream.CloseSend() + + // 3) Receive error reporting authentication failure. + if _, err = stream.Recv(); err == nil { + t.Fatal("Expected an error due to authentication failure.") + } + + if status.Code(err) != codes.Unauthenticated { + t.Fatalf("Expected Unauthenticated error, got: %v (code: %v)", err, status.Code(err)) + } + s.gnsiPathz.savePathzFileFreshess(s.config.PathzMetaFile) +} + +func TestGnsiPathzRunGet(t *testing.T) { + const testPort = 8084 // Use a different port to avoid conflict + s := createPathzServer(t, testPort) + go runServer(t, s) + defer s.Stop() + baseCtx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() // Always good practice to defer the cancel + spiffeURL, _ := url.Parse("spiffe://example.org/ns/default/sa/test-user") + p := &peer.Peer{ + Addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: testPort}, + AuthInfo: credentials.TLSInfo{ + SPIFFEID: spiffeURL, + }, + } + ctx := peer.NewContext(baseCtx, p) + + // Create gNSI.pathz client + tlsConfig := &tls.Config{InsecureSkipVerify: true} + cred := &loginCreds{Username: testUsername, Password: testPassword} + opts := []grpc.DialOption{ + grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), + grpc.WithPerRPCCredentials(cred), + } + targetAddr := fmt.Sprintf("127.0.0.1:%d", testPort) + conn, err := grpc.Dial(targetAddr, opts...) + if err != nil { + t.Fatalf("Dialing to %s failed: %v", targetAddr, err) + } + defer conn.Close() + //gClient := gnmipb.NewGNMIClient(conn) + + // Prepare Get request, the specific path doesn't matter, they should all + // fail due to the pathz policy + pathTgt := "OC_YANG" + + textPbPath := pathToPb("/openconfig-system:system/state/boot-time") + var pbPath gnmipb.Path + reqDataType := gnmipb.GetRequest_ALL + if err = proto.UnmarshalText(textPbPath, &pbPath); err != nil { + t.Fatal(err.Error()) + } + + prefix := gnmipb.Path{Target: pathTgt} + req := &gnmipb.GetRequest{ + Type: reqDataType, + Prefix: &prefix, + Path: []*gnmipb.Path{&pbPath}, + Encoding: gnmipb.Encoding_JSON_IETF, + } + + //_, errN := gClient.Get(ctx, req) + _, errN := s.Get(ctx, req) + if errN == nil { + t.Fatalf("Expected error, but passed") + } +} + +// TestGnsiPathzRunGet tests implementation of RunGet +func TestGnsiPathzRunGetErrSPIFFEID(t *testing.T) { + const testPort = 8084 // Use a different port to avoid conflict + s := createPathzServer(t, testPort) + go runServer(t, s) + defer s.Stop() + + // Create gNSI.pathz client + tlsConfig := &tls.Config{InsecureSkipVerify: true} + cred := &loginCreds{Username: testUsername, Password: testPassword} + opts := []grpc.DialOption{ + grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), + grpc.WithPerRPCCredentials(cred), + } + targetAddr := fmt.Sprintf("127.0.0.1:%d", testPort) + conn, err := grpc.Dial(targetAddr, opts...) + if err != nil { + t.Fatalf("Dialing to %s failed: %v", targetAddr, err) + } + defer conn.Close() + gClient := gnmipb.NewGNMIClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + + // Prepare Get request, the specific path doesn't matter, they should all + // fail due to the pathz policy + pathTgt := "OC_YANG" + + textPbPath := pathToPb("/openconfig-system:system/state/boot-time") + var pbPath gnmipb.Path + reqDataType := gnmipb.GetRequest_ALL + if err = proto.UnmarshalText(textPbPath, &pbPath); err != nil { + t.Fatal(err.Error()) + } + + prefix := gnmipb.Path{Target: pathTgt} + req := &gnmipb.GetRequest{ + Type: reqDataType, + Prefix: &prefix, + Path: []*gnmipb.Path{&pbPath}, + Encoding: gnmipb.Encoding_JSON_IETF, + } + + _, errN := gClient.Get(ctx, req) + if errN == nil { + t.Fatalf("Expected error, but passed") + } +} + +func TestGnsiPathzRunSet(t *testing.T) { + const testPort = 8084 // Use a different port to avoid conflict + s := createPathzServer(t, testPort) + go runServer(t, s) + defer s.Stop() + baseCtx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() // Always good practice to defer the cancel + spiffeURL, _ := url.Parse("spiffe://example.org/ns/default/sa/test-user") + p := &peer.Peer{ + Addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: testPort}, + AuthInfo: credentials.TLSInfo{ + SPIFFEID: spiffeURL, + }, + } + ctx := peer.NewContext(baseCtx, p) + + // Create gNSI.pathz client + tlsConfig := &tls.Config{InsecureSkipVerify: true} + cred := &loginCreds{Username: testUsername, Password: testPassword} + opts := []grpc.DialOption{ + grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), + grpc.WithPerRPCCredentials(cred), + } + targetAddr := fmt.Sprintf("127.0.0.1:%d", testPort) + conn, err := grpc.Dial(targetAddr, opts...) + if err != nil { + t.Fatalf("Dialing to %s failed: %v", targetAddr, err) + } + defer conn.Close() + //gClient := gnmipb.NewGNMIClient(conn) + + // Prepare Get request, the specific path doesn't matter, they should all + // fail due to the pathz policy + pathTgt := "OC_YANG" + + textPbPath := pathToPb("/openconfig-system:system/state/boot-time") + var pbPath gnmipb.Path + if err = proto.UnmarshalText(textPbPath, &pbPath); err != nil { + t.Fatal(err.Error()) + } + + prefix := gnmipb.Path{Target: pathTgt} + req := &gnmipb.SetRequest{ + Prefix: &gnmipb.Path{Elem: []*gnmipb.PathElem{{Name: "interfaces"}}}, + Update: []*gnmipb.Update{ + newPbUpdate("interface[name=Ethernet0]/config/mtu", `{"mtu": 9104}`), + newPbUpdate("interface[name=Ethernet4]/config/mtu", `{"mtu": 9105}`), + }} + req = &gnmipb.SetRequest{ + Prefix: &prefix, + } + _, errN := s.Set(ctx, req) + + if errN == nil { + t.Fatalf("Expected error, but passed") + } +} +func TestGnsiPathzRunSetErrSPIFFEID(t *testing.T) { + const testPort = 8084 // Use a different port to avoid conflict + s := createPathzServer(t, testPort) + go runServer(t, s) + defer s.Stop() + baseCtx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() // Always good practice to defer the cancel + spiffeURL, _ := url.Parse("spiffe://example.org/ns/default/sa/test-user") + p := &peer.Peer{ + Addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: testPort}, + AuthInfo: credentials.TLSInfo{ + SPIFFEID: spiffeURL, + }, + } + ctx := peer.NewContext(baseCtx, p) + + // Create gNSI.pathz client + tlsConfig := &tls.Config{InsecureSkipVerify: true} + cred := &loginCreds{Username: testUsername, Password: testPassword} + opts := []grpc.DialOption{ + grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), + grpc.WithPerRPCCredentials(cred), + } + targetAddr := fmt.Sprintf("127.0.0.1:%d", testPort) + conn, err := grpc.Dial(targetAddr, opts...) + if err != nil { + t.Fatalf("Dialing to %s failed: %v", targetAddr, err) + } + defer conn.Close() + gClient := gnmipb.NewGNMIClient(conn) + + // Prepare Get request, the specific path doesn't matter, they should all + // fail due to the pathz policy + pathTgt := "OC_YANG" + + textPbPath := pathToPb("/openconfig-system:system/state/boot-time") + var pbPath gnmipb.Path + if err = proto.UnmarshalText(textPbPath, &pbPath); err != nil { + t.Fatal(err.Error()) + } + + prefix := gnmipb.Path{Target: pathTgt} + req := &gnmipb.SetRequest{ + Prefix: &gnmipb.Path{Elem: []*gnmipb.PathElem{{Name: "interfaces"}}}, + Update: []*gnmipb.Update{ + newPbUpdate("interface[name=Ethernet0]/config/mtu", `{"mtu": 9104}`), + newPbUpdate("interface[name=Ethernet4]/config/mtu", `{"mtu": 9105}`), + }} + req = &gnmipb.SetRequest{ + Prefix: &prefix, + } + _, errN := gClient.Set(ctx, req) + + if errN == nil { + t.Fatalf("Expected error, but passed") + } +} +func resetPathzPolicyFile(path string) error { + return attemptWrite(path, []byte(pathzTestPolicyPermit), 0600) +} + +// TestGnsiPathzUnimplemented tests implementation of gnsi.pathz Probe and Get server. +func TestGnsiPathzUnimplemented(t *testing.T) { + // Setup is similar to TestGnsiPathzRotation, but we don't need the full rotation logic. + + const testPort = 8082 // Use a different port to avoid conflict + s := createPathzServer(t, testPort) + go runServer(t, s) + defer s.Stop() + + // Create gNSI.pathz client + tlsConfig := &tls.Config{InsecureSkipVerify: true} + cred := &loginCreds{Username: testUsername, Password: testPassword} + opts := []grpc.DialOption{ + grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), + grpc.WithPerRPCCredentials(cred), + } + targetAddr := fmt.Sprintf("127.0.0.1:%d", testPort) + conn, err := grpc.Dial(targetAddr, opts...) + if err != nil { + t.Fatalf("Dialing to %s failed: %v", targetAddr, err) + } + defer conn.Close() + sc := pathz.NewPathzClient(conn) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + // --- Test Probe RPC --- + t.Run("ProbeUnimplemented", func(t *testing.T) { + _, err := sc.Probe(ctx, &pathz.ProbeRequest{}) + if status.Code(err) != codes.Unimplemented { + t.Fatalf("Probe() returned unexpected error code: got %v, want %v", status.Code(err), codes.Unimplemented) + } + }) + + // --- Test Get RPC --- + t.Run("GetUnimplemented", func(t *testing.T) { + _, err := sc.Get(ctx, &pathz.GetRequest{}) + if status.Code(err) != codes.Unimplemented { + t.Fatalf("Get() returned unexpected error code: got %v, want %v", status.Code(err), codes.Unimplemented) + } + }) +} + +// TestGnsiPathzMisc tests implementation of gnsi.pathz other functions used. + +func TestGnsiPathzMisc(t *testing.T) { + // --- Test copyFile Error scenarios --- + t.Run("PathzCopyFile", func(t *testing.T) { + if err := copyFile("test", ""); err == nil { + t.Error("expected: error, got: nil") + } + }) + + t.Run("PathzCopyNonRegularFile", func(t *testing.T) { + // 1. Create a temporary directory to use as the invalid input + tempDir, err := os.MkdirTemp("", "testdir") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + // Schedule cleanup to remove the temp directory after the test finishes + defer os.RemoveAll(tempDir) + if err := copyFile(tempDir, ""); err == nil { + t.Error("expected: error, got: nil") + } + }) + t.Run("PathzCopyFileDstErr", func(t *testing.T) { + // 1. Create a temporary directory to use as the invalid input + _, err := os.Create("tempFile") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + // Schedule cleanup to remove the temp directory after the test finishes + defer os.Remove("tempFile") + if err := copyFile("", "tempFile"); err == nil { + t.Error("expected: error, got: nil") + } + }) + t.Run("PathzCopyFileSrcErr", func(t *testing.T) { + // 1. Create a temporary directory to use as the invalid input + _, err := os.Create("tempFile") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + // Schedule cleanup to remove the temp directory after the test finishes + defer os.Remove("tempFile") + if err := copyFile("tempFile", ""); err == nil { + t.Error("expected: error, got: nil") + } + }) + // --- Test fileCheck Error scenarios --- + t.Run("PathzFileCheckNonRegularFile", func(t *testing.T) { + // 1. Create a temporary directory to use as the invalid input + tempDir, err := os.MkdirTemp("", "testdir") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + // Schedule cleanup to remove the temp directory after the test finishes + defer os.RemoveAll(tempDir) + if err := fileCheck(tempDir); err == nil { + t.Error("expected: error, got: nil") + } + }) +} +func generatePathzCreatedOn() uint64 { + return uint64(time.Now().UnixNano()) +} + +func generatePathzVersion() string { + return fmt.Sprintf("%d", time.Now().UnixNano()) +} + +const pathzTestPolicyPermit = `rules: < + id: "Rule1" + user: "User1" + path: < + > + action: ACTION_PERMIT + mode: MODE_READ +> +groups: < + name: "Group1" + users: < + name: "User1" + > + users: < + name: "User2" + > +> +` +const pathzTestPolicyDeny = `rules: < + id: "Rule1" + user: "User1" + path: < + > + action: ACTION_DENY + mode: MODE_READ +> +groups: < + name: "Group1" + users: < + name: "User1" + > + users: < + name: "User2" + > +> +` diff --git a/gnmi_server/server.go b/gnmi_server/server.go index c18b88cc9..7bd0c2b4e 100644 --- a/gnmi_server/server.go +++ b/gnmi_server/server.go @@ -17,6 +17,7 @@ import ( "time" "github.com/Azure/sonic-mgmt-common/translib" + gnsi_pathz_pb "github.com/openconfig/gnsi/pathz" "github.com/sonic-net/sonic-gnmi/common_utils" "github.com/sonic-net/sonic-gnmi/pkg/bypass" operationalhandler "github.com/sonic-net/sonic-gnmi/pkg/server/operational-handler" @@ -34,6 +35,7 @@ import ( gnoi_containerz_pb "github.com/openconfig/gnoi/containerz" "github.com/openconfig/gnoi/factory_reset" gnoi_system_pb "github.com/openconfig/gnoi/system" + "google.golang.org/grpc/credentials" gnoi_file_pb "github.com/openconfig/gnoi/file" gnoi_healthz_pb "github.com/openconfig/gnoi/healthz" @@ -90,9 +92,11 @@ type Server struct { masterEID uint128 gnoi_system_pb.UnimplementedSystemServer factory_reset.UnimplementedFactoryResetServer - gnsiCertz *GNSICertzServer - authzWatcher *authz.FileWatcherInterceptor - gnsiAuthz *GNSIAuthzServer + gnsiCertz *GNSICertzServer + authzWatcher *authz.FileWatcherInterceptor + gnsiAuthz *GNSIAuthzServer + gnsiPathz *GNSIPathzServer + ConnectionManager *ConnectionManager } // handleOperationalGet handles OPERATIONAL target requests directly with standard gNMI types @@ -226,6 +230,9 @@ type Config struct { AuthzPolicy bool // Enable authz policy. AuthzPolicyFile string // Path to JSON file with authz policies. AuthzMetaFile string // Path to JSON file with authz metadata. + PathzPolicy bool // Enable gNMI pathz policy. + PathzPolicyFile string // Path to gNMI pathz policy file. + PathzMetaFile string // Path to JSON file with pathz metadata. } // DBusOSBackend is a concrete implementation of OSBackend @@ -316,11 +323,12 @@ func (i AuthTypes) Unset(mode string) error { // registerAllServices registers all gNMI and gNOI services on the given gRPC server. func registerAllServices(s *grpc.Server, srv *Server, fileSrv *FileServer, osSrv *OSServer, containerzSrv *ContainerzServer, - debugSrv *DebugServer, healthzSrv *HealthzServer, certzSrv *GNSICertzServer, authzSrv *GNSIAuthzServer) { + debugSrv *DebugServer, healthzSrv *HealthzServer, certzSrv *GNSICertzServer, authzSrv *GNSIAuthzServer, pathzSrv *GNSIPathzServer) { gnmipb.RegisterGNMIServer(s, srv) factory_reset.RegisterFactoryResetServer(s, srv) gnsi_certz_pb.RegisterCertzServer(s, certzSrv) gnsi_authz_pb.RegisterAuthzServer(s, authzSrv) + gnsi_pathz_pb.RegisterPathzServer(s, pathzSrv) spb_jwt_gnoi.RegisterSonicJwtServiceServer(s, srv) if srv.config.EnableTranslibWrite || srv.config.EnableNativeWrite { gnoi_system_pb.RegisterSystemServer(s, srv) @@ -525,7 +533,8 @@ func NewServer(config *Config, tlsOpts []grpc.ServerOption, commonOpts []grpc.Se healthzSrv := &HealthzServer{Server: srv} authzSrv := NewGNSIAuthzServer(srv) srv.gnsiAuthz = authzSrv - + pathzSrv := NewGNSIPathzServer(srv) + srv.gnsiPathz = pathzSrv readWhitelist, writeWhitelist := gnoi_debug.ConstructWhitelists() debugSrv := &DebugServer{ Server: srv, @@ -548,7 +557,7 @@ func NewServer(config *Config, tlsOpts []grpc.ServerOption, commonOpts []grpc.Se return nil, fmt.Errorf("failed to open listener port %d: %v", config.Port, err) } - registerAllServices(srv.s, srv, fileSrv, osSrv, containerzSrv, debugSrv, healthzSrv, certzSrv, authzSrv) + registerAllServices(srv.s, srv, fileSrv, osSrv, containerzSrv, debugSrv, healthzSrv, certzSrv, authzSrv, pathzSrv) } // UDS Server (UnixSocket set) @@ -584,7 +593,7 @@ func NewServer(config *Config, tlsOpts []grpc.ServerOption, commonOpts []grpc.Se srv.udsListener = nil srv.udsServer = nil } else { - registerAllServices(srv.udsServer, srv, fileSrv, osSrv, containerzSrv, debugSrv, healthzSrv, certzSrv, authzSrv) + registerAllServices(srv.udsServer, srv, fileSrv, osSrv, containerzSrv, debugSrv, healthzSrv, certzSrv, authzSrv, pathzSrv) } } @@ -849,6 +858,23 @@ func (s *Server) Get(ctx context.Context, req *gnmipb.GetRequest) (*gnmipb.GetRe common_utils.IncCounter(common_utils.GNMI_GET_FAIL) return nil, status.Errorf(codes.Unimplemented, "unsupported request type: %s", gnmipb.GetRequest_DataType_name[int32(req.GetType())]) } + // gNMI path based authorization + if s.config.PathzPolicy && len(req.GetPath()) != 0 { + newPaths := []*gnmipb.Path{} + user, err := getUsername(ctx) + if err != nil { + log.V(1).Infof("GetRequest User not found: %s", err.Error()) + return nil, err + } + for _, path := range req.GetPath() { + // Only process the authorized paths in the request. + s.gnsiPathz.pathzProcessor.AuthorizeWithPrefix(user, req.GetPrefix(), path, gnsi_pathz_pb.Mode_MODE_READ) + } + if len(newPaths) == 0 { + return nil, status.Error(codes.PermissionDenied, "Unauthorized request. Rejected by pathz policy.") + } + req.Path = newPaths + } if err := s.checkEncodingAndModel(req.GetEncoding(), req.GetUseModels()); err != nil { common_utils.IncCounter(common_utils.GNMI_GET_FAIL) @@ -967,6 +993,27 @@ func (s *Server) Set(ctx context.Context, req *gnmipb.SetRequest) (*gnmipb.SetRe common_utils.IncCounter(common_utils.GNMI_SET_FAIL) return nil, grpc.Errorf(codes.Unimplemented, "GNMI is in read-only mode") } + // gNMI path based authorization + if s.config.PathzPolicy { + user, err := getUsername(ctx) + if err != nil { + log.V(1).Infof("SetRequest User not found: %s", err.Error()) + return nil, err + } + permitted := true + for _, path := range req.GetDelete() { + s.gnsiPathz.pathzProcessor.AuthorizeWithPrefix(user, req.GetPrefix(), path, gnsi_pathz_pb.Mode_MODE_WRITE) + } + for _, update := range req.GetReplace() { + s.gnsiPathz.pathzProcessor.AuthorizeWithPrefix(user, req.GetPrefix(), update.GetPath(), gnsi_pathz_pb.Mode_MODE_WRITE) + } + for _, update := range req.GetUpdate() { + s.gnsiPathz.pathzProcessor.AuthorizeWithPrefix(user, req.GetPrefix(), update.GetPath(), gnsi_pathz_pb.Mode_MODE_WRITE) + } + if !permitted { + return nil, status.Error(codes.PermissionDenied, "Unauthorized request. Rejected by pathz policy.") + } + } var results []*gnmipb.UpdateResult /* Fetch the prefix. */ @@ -1127,6 +1174,28 @@ func (s *Server) Capabilities(ctx context.Context, req *gnmipb.CapabilityRequest Extension: exts}, nil } +// Obtain the user name as the last element of the SPIFFE ID. +func getUsername(ctx context.Context) (string, error) { + pr, ok := peer.FromContext(ctx) + if !ok { + return "", grpc.Errorf(codes.Unauthenticated, "failed to get peer from ctx") + } + tlsInfo, ok := pr.AuthInfo.(credentials.TLSInfo) + if !ok { + return "", grpc.Errorf(codes.Unauthenticated, "no tls info was found") + } + spiffe := tlsInfo.SPIFFEID + if spiffe == nil { + return "", grpc.Errorf(codes.Unauthenticated, "failed to get SPIFFE ID") + } + path := spiffe.Path + usernamePos := strings.LastIndex(path, "/") + if usernamePos == -1 { + return "", status.Errorf(codes.Unauthenticated, "failed to get username from SPIFFE ID: %s", spiffe) + } + return path[usernamePos+1:], nil +} + type uint128 struct { High uint64 Low uint64 diff --git a/go.mod b/go.mod index 48741acc4..7e78a95dc 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,7 @@ module github.com/sonic-net/sonic-gnmi go 1.19 + require ( github.com/Azure/sonic-mgmt-common v0.0.0-00010101000000-000000000000 github.com/Workiva/go-datastructures v1.0.50 @@ -17,6 +18,7 @@ require ( github.com/golang/mock v1.6.0 github.com/golang/protobuf v1.5.4 github.com/google/gnxi v0.0.0-20181220173256-89f51f0ce1e2 + github.com/google/go-cmp v0.7.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/kylelemons/godebug v1.1.0 github.com/maruel/natural v1.1.1 @@ -50,7 +52,6 @@ require ( github.com/envoyproxy/go-control-plane v0.12.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.0.4 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/onsi/ginkgo v1.10.3 // indirect github.com/onsi/gomega v1.7.1 // indirect github.com/openconfig/goyang v0.0.0-20200309174518-a00bece872fc // indirect diff --git a/go.sum b/go.sum index d444bf6db..c87cdca7b 100644 --- a/go.sum +++ b/go.sum @@ -1350,7 +1350,9 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/c9s/goprocinfo v0.0.0-20191125144613-4acdd056c72d h1:MQGrhPHSxg08x+LKgQTOnnjfXt+p+128WCECqAYXJsU= github.com/c9s/goprocinfo v0.0.0-20191125144613-4acdd056c72d/go.mod h1:uEyr4WpAH4hio6LFriaPkL938XnrvLpNPmQHBdrmbIE= github.com/cenkalti/backoff/v4 v4.0.0/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg= @@ -1417,6 +1419,7 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -2429,6 +2432,7 @@ google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/grpc/examples v0.0.0-20201112215255-90f1b3ee835b h1:NuxyvVZoDfHZwYW9LD4GJiF5/nhiSyP4/InTrvw9Ibk= +google.golang.org/grpc/examples v0.0.0-20201112215255-90f1b3ee835b/go.mod h1:IBqQ7wSUJ2Ep09a8rMWFsg4fmI2r38zwsq8a0GgxXpM= google.golang.org/grpc/security/advancedtls v1.0.0 h1:/KQ7VP/1bs53/aopk9QhuPyFAp9Dm9Ejix3lzYkCrDA= google.golang.org/grpc/security/advancedtls v1.0.0/go.mod h1:o+s4go+e1PJ2AjuQMY5hU82W7lDlefjJA6FqEHRVHWk= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= diff --git a/pathz_authorizer/pathz_authorizer.go b/pathz_authorizer/pathz_authorizer.go new file mode 100644 index 000000000..4d4cfde39 --- /dev/null +++ b/pathz_authorizer/pathz_authorizer.go @@ -0,0 +1,533 @@ +package pathz_authorizer + +import ( + "fmt" + "os" + "sort" + "sync" + + log "github.com/golang/glog" + "github.com/golang/protobuf/proto" + gnmipb "github.com/openconfig/gnmi/proto/gnmi" + pathzpb "github.com/openconfig/gnsi/pathz" +) + +const ( + wildCard = "*" +) + +var exists = struct{}{} + +type stringSet map[string]interface{} + +// PrintPathWithPrefix returns the string represtation of a path. +func PrintPathWithPrefix(prefix, path *gnmipb.Path) string { + netPath := []*gnmipb.PathElem{} + netPath = append(netPath, prefix.GetElem()...) + netPath = append(netPath, path.GetElem()...) + return printPath(netPath) +} + +func printPath(path []*gnmipb.PathElem) string { + ret := "" + for _, e := range path { + ret += "/" + e.GetName() + if len(e.GetKey()) != 0 { + // We sort all keys in alphabetical order. + keys := []string{} + for k := range e.GetKey() { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + ret += "[" + k + "=" + e.GetKey()[k] + "]" + } + } + } + if ret == "" { + ret = "/" + } + return ret +} + +// Result stores the gNMI authorization result. +type Result struct { + // Action can be pathzpb.Action_ACTION_UNSPECIFIED, which indicates that no matching + // rule is found. Callers should apply deny action if no rule is + // matched. + Action pathzpb.Action + // The matched configuration rule ID. + // This field is empty if there is no matched rule. + RuleId string + // The matched configuration rule. + // This field is the gNMI path in the configuration rule in string format. + // This field is empty if there is no matched rule. + MatchedRule string +} + +type ruleAction struct { + ruleId string + action pathzpb.Action +} + +// Read, subscribe, and write permissions are independently configured. +// It is possible that a user have write permission but not read permission on +// the same gNMI path. +type permission struct { + read ruleAction + write ruleAction +} + +// A node can be a leaf node, name node, or a key node. +// A leaf node only contains permision info. There is no next node. +// A name node is used to look up the next name field in gNMI PathElem. +// A key node is used to look up the next key field in gNMI PathElem. +type gnmiAuthzNode struct { + users map[string]permission + groups map[string]permission + // The string representation of the gNMI path in the rule. + rule string + // The nameNext field stores the next nodes for all the next path names + // configured in the rule. + nameNext map[string]*gnmiAuthzNode + // The key field stores the next key name. (All keys must be sorted in + // alphabetical order in gNMI PathElem.) If the key field is not empty, the + // keyNext field should not be empty. + key string + // The keyNext field stores the next nodes for all the key values configured + // in the rule. The "*" character is a wild card. + keyNext map[string]*gnmiAuthzNode +} + +// GnmiAuthzProcessor performs the main gNMI authorization logic. +type GnmiAuthzProcessor struct { + groups map[string]stringSet + root *gnmiAuthzNode + policy *pathzpb.AuthorizationPolicy + mux sync.Mutex +} + +// GnmiAuthzProcessorInterface defines the gNMI authorization processor +// interface. +type GnmiAuthzProcessorInterface interface { + // Given a user, a gNMI path, and the access mode (read or write), returns + // the authorization result. + // It is recommended to input leaf paths for correct authorization result. + // If a subtree path is passed, detailed rules under the subtree will not + // be matched. + Authorize(user string, path *gnmipb.Path, mode pathzpb.Mode) (*Result, error) + + // Same as Authorize, with a path prefix. + AuthorizeWithPrefix(user string, prefix, path *gnmipb.Path, mode pathzpb.Mode) (*Result, error) + + // Given the policy file in proto text format, update the authorization + // policy. The processor will start with an empty policy that denies all + // access. If error is returned, the processor's current policy will not + // change. + UpdatePolicyFromFile(policyFile string) error + + // Same as UpdatePolicyFromFile, but takes a proto instead of a file name. + UpdatePolicyFromProto(policyProto *pathzpb.AuthorizationPolicy) error + + // Return the current policy. + // Return nil if no policy has been successfully configured, in such case + // the processor will deny all access. + GetPolicy() *pathzpb.AuthorizationPolicy +} + +func (result *Result) updateResult(p permission, mode pathzpb.Mode, rule string) error { + if result == nil { + return fmt.Errorf("result is nil") + } + // If the result is already DENY, we don't update it. + if result.Action == pathzpb.Action_ACTION_DENY { + return nil + } + // Read and write permissions are checked independently. + // It is possible that a user have write permission but not read permission + // on the same gNMI path. + var access ruleAction + switch mode { + case pathzpb.Mode_MODE_READ: + access = p.read + case pathzpb.Mode_MODE_WRITE: + access = p.write + default: + return fmt.Errorf("invalid mode") + } + if access.action != pathzpb.Action_ACTION_UNSPECIFIED { + result.Action = access.action + result.RuleId = access.ruleId + result.MatchedRule = rule + } + return nil +} + +func (result *Result) logResult(user string, path *gnmipb.Path, mode pathzpb.Mode) { + modeStr := "read" + if mode == pathzpb.Mode_MODE_WRITE { + modeStr = "write" + } + // Always log denied cases. + if result.Action == pathzpb.Action_ACTION_UNSPECIFIED { + log.V(2).Infof("User %s with %s request on %s does not match any gNMI ACL rule. Request denied.", user, modeStr, printPath(path.GetElem())) + } else if result.Action == pathzpb.Action_ACTION_DENY { + log.V(2).Infof("User %s with %s request on %s matched gNMI ACL rule %s (rule ID: %s). Request denied.", user, modeStr, printPath(path.GetElem()), result.MatchedRule, result.RuleId) + } +} + +func (p *permission) updatePermission(a pathzpb.Action, m pathzpb.Mode, ruleId string) error { + if p == nil { + return fmt.Errorf("permission cannot be nil") + } + if a == pathzpb.Action_ACTION_UNSPECIFIED { + return nil + } + var r *ruleAction + switch m { + case pathzpb.Mode_MODE_UNSPECIFIED: + return nil + case pathzpb.Mode_MODE_READ: + r = &p.read + case pathzpb.Mode_MODE_WRITE: + r = &p.write + default: + return fmt.Errorf("invalid mode") + } + // If multiple rules configure the same path with both permit and + // deny actions, we will honor the deny rule. + if r.action == pathzpb.Action_ACTION_DENY { + return nil + } + r.action = a + r.ruleId = ruleId + return nil +} + +func (node *gnmiAuthzNode) authorize(user string, path []*gnmipb.PathElem, mode pathzpb.Mode, nameIdx int, keys []string, keyIdx int, groups map[string]stringSet) Result { + ret := Result{Action: pathzpb.Action_ACTION_UNSPECIFIED} + if node == nil { + return ret + } + + if permission, ok := node.users[user]; ok { + ret.updateResult(permission, mode, node.rule) + } + + // Not going to check the group rules if the user is in the user rules. + if ret.Action == pathzpb.Action_ACTION_UNSPECIFIED || ret.MatchedRule == "" { + for group, permission := range node.groups { + if members, ok := groups[group]; ok { + if _, ok := members[user]; ok { + ret.updateResult(permission, mode, node.rule) + // A user can be in multiple groups, we check all groups + // unless the result is DENY. + if ret.Action == pathzpb.Action_ACTION_DENY && ret.MatchedRule != "" { + break + } + } + } + } + } + + // Return if it is the end of the path. + if nameIdx >= len(path) { + return ret + } + + // In the process of key lookup. + if len(keys) > 0 { + // Key index should not go out of range. + if keyIdx >= len(keys) { + log.V(0).Infof("Key index got out of range for key list %v", keys) + return ret + } + + // Key value mismatches. + if keys[keyIdx] != node.key { + log.V(0).Infof("Key value mismatches. Expect %v, got %v", node.key, keys[keyIdx]) + return ret + } + + // Key name does not exist in path. + keyValue, ok := path[nameIdx].GetKey()[node.key] + if !ok { + log.V(0).Infof("Key name %v does not exist in path", node.key) + return ret + } + + nextNameIdx := nameIdx + nextKeys := keys + nextKeyIdx := keyIdx + 1 + + // Exit key lookup process if it reaches the end of the key list. + if nextKeyIdx == len(keys) { + nextNameIdx = nameIdx + 1 + nextKeys = nil + nextKeyIdx = 0 + } + + // First, we will check exact match. + if nextNode, ok := node.keyNext[keyValue]; ok { + // Return if we get a hit in exact key match. We will NOT check + // wild card rule even if it might match longer prefix. Notice + // that the keys are stored in alphabetical order if there are + // multiple keys. We will return the first one that got matched. + r := nextNode.authorize(user, path, mode, nextNameIdx, nextKeys, nextKeyIdx, groups) + if r.Action != pathzpb.Action_ACTION_UNSPECIFIED && r.MatchedRule != "" { + return r + } + } + + // Then, check wildcard. + if nextNode, ok := node.keyNext[wildCard]; ok { + r := nextNode.authorize(user, path, mode, nextNameIdx, nextKeys, nextKeyIdx, groups) + if r.Action != pathzpb.Action_ACTION_UNSPECIFIED && r.MatchedRule != "" { + return r + } + } + + return ret + } + + if nextNode, ok := node.nameNext[path[nameIdx].GetName()]; ok { + nextNameIdx := nameIdx + 1 + var nextKeys []string + + // Check if it needs to perform key lookup process. + if len(path[nameIdx].GetKey()) > 0 { + nextNameIdx = nameIdx + nextKeys = []string{} + // Build a list of stored keys. + for k := range path[nameIdx].GetKey() { + nextKeys = append(nextKeys, k) + } + sort.Strings(nextKeys) + } + + r := nextNode.authorize(user, path, mode, nextNameIdx, nextKeys, 0, groups) + if r.Action != pathzpb.Action_ACTION_UNSPECIFIED && r.MatchedRule != "" { + return r + } + } + + return ret +} + +func (node *gnmiAuthzNode) insertPath(rule *pathzpb.AuthorizationRule, nameIdx int, keys []string, keyIdx int) error { + if node == nil { + return fmt.Errorf("node cannot be nil") + } + + // Building a leaf node. + if nameIdx >= len(rule.GetPath().GetElem()) { + node.rule = printPath(rule.GetPath().GetElem()) + switch rule.GetPrincipal().(type) { + case *pathzpb.AuthorizationRule_User: + if node.users == nil { + node.users = map[string]permission{} + } + _, ok := node.users[rule.GetUser()] + if !ok { + node.users[rule.GetUser()] = permission{ + read: ruleAction{action: pathzpb.Action_ACTION_UNSPECIFIED}, + write: ruleAction{action: pathzpb.Action_ACTION_UNSPECIFIED}, + } + } + p := node.users[rule.GetUser()] + p.updatePermission(rule.GetAction(), rule.GetMode(), rule.GetId()) + node.users[rule.GetUser()] = p + case *pathzpb.AuthorizationRule_Group: + if node.groups == nil { + node.groups = map[string]permission{} + } + _, ok := node.groups[rule.GetGroup()] + if !ok { + node.groups[rule.GetGroup()] = permission{ + read: ruleAction{action: pathzpb.Action_ACTION_UNSPECIFIED}, + write: ruleAction{action: pathzpb.Action_ACTION_UNSPECIFIED}, + } + } + p := node.groups[rule.GetGroup()] + p.updatePermission(rule.GetAction(), rule.GetMode(), rule.GetId()) + node.groups[rule.GetGroup()] = p + default: + return fmt.Errorf("invalid principal type") + } + return nil + } + + // Check if it is building a key node + if len(keys) > 0 { + if keyIdx >= len(keys) { + return fmt.Errorf("key index out of range") + } + v, ok := rule.GetPath().GetElem()[nameIdx].GetKey()[keys[keyIdx]] + if !ok { + return fmt.Errorf("key %v not found in path", keys[keyIdx]) + } + if node.key != "" && node.key != keys[keyIdx] { + return fmt.Errorf("key %v mismatch from other configured rule", node.key) + } + node.key = keys[keyIdx] + if node.keyNext == nil { + node.keyNext = map[string]*gnmiAuthzNode{} + } + if _, ok := node.keyNext[v]; !ok { + node.keyNext[v] = &gnmiAuthzNode{} + } + nextNameIdx := nameIdx + nextKeys := keys + nextKeyIdx := keyIdx + 1 + // Exit key node if it reaches the end of the key list. + if nextKeyIdx == len(keys) { + nextNameIdx = nameIdx + 1 + nextKeys = nil + nextKeyIdx = 0 + } + return node.keyNext[v].insertPath(rule, nextNameIdx, nextKeys, nextKeyIdx) + } + + // Building a name node. + if node.nameNext == nil { + node.nameNext = map[string]*gnmiAuthzNode{} + } + name := rule.GetPath().GetElem()[nameIdx].GetName() + if _, ok := node.nameNext[name]; !ok { + node.nameNext[name] = &gnmiAuthzNode{} + } + nextNameIdx := nameIdx + 1 + var nextKeys []string + // Check if it needs to switch to key node. + if len(rule.GetPath().GetElem()[nameIdx].GetKey()) > 0 { + nextNameIdx = nameIdx + nextKeys = []string{} + // Build a list of stored keys. + for k := range rule.GetPath().GetElem()[nameIdx].GetKey() { + nextKeys = append(nextKeys, k) + } + sort.Strings(nextKeys) + } + return node.nameNext[name].insertPath(rule, nextNameIdx, nextKeys, 0) +} + +// Authorize is a GnmiAuthzProcessorInterface method. +func (processor *GnmiAuthzProcessor) Authorize(user string, path *gnmipb.Path, mode pathzpb.Mode) (*Result, error) { + if processor == nil { + log.V(0).Info("Authorize error: nil pointer") + return nil, fmt.Errorf("processor cannot be nil") + } + if mode == pathzpb.Mode_MODE_UNSPECIFIED { + log.V(0).Infof("Authorize error: undefined access mode") + return nil, fmt.Errorf("mode must be read or write") + } + + processor.mux.Lock() + defer processor.mux.Unlock() + r := processor.root.authorize(user, path.GetElem(), mode, 0, nil, 0, processor.groups) + r.logResult(user, path, mode) + return &r, nil +} + +// Authorize is a GnmiAuthzProcessorInterface method. +func (processor *GnmiAuthzProcessor) AuthorizeWithPrefix(user string, prefix, path *gnmipb.Path, mode pathzpb.Mode) (*Result, error) { + if processor == nil { + log.V(0).Info("Authorize error: nil pointer") + return nil, fmt.Errorf("processor cannot be nil") + } + if mode == pathzpb.Mode_MODE_UNSPECIFIED { + log.V(0).Infof("Authorize error: undefined access mode") + return nil, fmt.Errorf("mode must be read or write") + } + netPath := []*gnmipb.PathElem{} + netPath = append(netPath, prefix.GetElem()...) + netPath = append(netPath, path.GetElem()...) + + processor.mux.Lock() + defer processor.mux.Unlock() + r := processor.root.authorize(user, netPath, mode, 0, nil, 0, processor.groups) + r.logResult(user, path, mode) + return &r, nil +} + +// UpdatePolicyFromFile is a GnmiAuthzProcessorInterface method. +func (processor *GnmiAuthzProcessor) UpdatePolicyFromFile(policyFile string) error { + if processor == nil { + log.V(0).Info("UpdatePolicyFromFile error: nil pointer") + return fmt.Errorf("processor cannot be nil") + } + + content, err := os.ReadFile(policyFile) + if err != nil { + log.V(0).Infof("Failed to open file %s.", policyFile) + return err + } + policy := &pathzpb.AuthorizationPolicy{} + err = proto.UnmarshalText(string(content), policy) + if err != nil { + log.V(0).Infof("Failed to parse file %s.", policyFile) + return err + } + return processor.UpdatePolicyFromProto(policy) +} + +// UpdatePolicyFromProto is a GnmiAuthzProcessorInterface method. +func (processor *GnmiAuthzProcessor) UpdatePolicyFromProto(policyProto *pathzpb.AuthorizationPolicy) error { + if processor == nil { + log.V(0).Info("UpdatePolicyFromProto error: nil pointer") + return fmt.Errorf("processor cannot be nil") + } + + newRoot, newGroups, err := createNewPolicy(policyProto) + if err != nil { + log.V(0).Infof("Failed to create new gNMI authorization rule.") + return err + } + processor.mux.Lock() + defer processor.mux.Unlock() + processor.root = newRoot + processor.groups = newGroups + processor.policy = policyProto + return nil +} + +// GetPolicy is a GnmiAuthzProcessorInterface method. +func (processor *GnmiAuthzProcessor) GetPolicy() *pathzpb.AuthorizationPolicy { + if processor == nil { + return nil + } + processor.mux.Lock() + defer processor.mux.Unlock() + return processor.policy +} + +func createGroups(groupsProto []*pathzpb.Group) map[string]stringSet { + groups := map[string]stringSet{} + for _, g := range groupsProto { + if _, ok := groups[g.GetName()]; !ok { + groups[g.GetName()] = stringSet{} + } + for _, u := range g.GetUsers() { + groups[g.GetName()][u.GetName()] = exists + } + } + return groups +} + +func createPolicies(rules []*pathzpb.AuthorizationRule) (*gnmiAuthzNode, error) { + if len(rules) == 0 { + return nil, fmt.Errorf("no rules found") + } + root := &gnmiAuthzNode{} + for _, p := range rules { + if err := root.insertPath(p, 0, nil, 0); err != nil { + return nil, err + } + } + return root, nil +} + +func createNewPolicy(policyProto *pathzpb.AuthorizationPolicy) (*gnmiAuthzNode, map[string]stringSet, error) { + rules, err := createPolicies(policyProto.GetRules()) + return rules, createGroups(policyProto.GetGroups()), err +} diff --git a/pathz_authorizer/pathz_authorizer_test.go b/pathz_authorizer/pathz_authorizer_test.go new file mode 100644 index 000000000..1345cf0a6 --- /dev/null +++ b/pathz_authorizer/pathz_authorizer_test.go @@ -0,0 +1,503 @@ +package pathz_authorizer + +import ( + "fmt" + "reflect" + "strings" + "testing" + + gnmipb "github.com/openconfig/gnmi/proto/gnmi" + pathzpb "github.com/openconfig/gnsi/pathz" +) + +type rule struct { + path string + user string + group string + action pathzpb.Action + mode pathzpb.Mode +} + +type group struct { + name string + members []string +} + +// Converts a gNMI path from string to proto. +func getGnmiPath(path string) (*gnmipb.Path, error) { + ret := []*gnmipb.PathElem{} + elems := strings.Split(path, "/") + for _, e := range elems { + if e == "" { + continue + } + pe := &gnmipb.PathElem{} + kvs := strings.Split(e, "[") + for i, kv := range kvs { + if i == 0 { + pe.Name = kv + continue + } + kvp := strings.Split(kv[:len(kv)-1], "=") + if len(kvp) != 2 { + return nil, fmt.Errorf("invalid path elem %v", e) + } + if pe.Key == nil { + pe.Key = map[string]string{} + } + pe.Key[kvp[0]] = kvp[1] + } + ret = append(ret, pe) + } + return &gnmipb.Path{Elem: ret}, nil +} + +func getGnmiAuthzConfig(rules []*rule) ([]*pathzpb.AuthorizationRule, error) { + ret := []*pathzpb.AuthorizationRule{} + for _, p := range rules { + ruleId := p.path + "_" + pathzpb.Mode_name[int32(p.mode)] + "_" + pathzpb.Action_name[int32(p.action)] + path, err := getGnmiPath(p.path) + if err != nil { + return nil, err + } + if p.user != "" { + rule := &pathzpb.AuthorizationRule{ + Id: ruleId + "_user", + Principal: &pathzpb.AuthorizationRule_User{User: p.user}, + Path: path, + Action: p.action, + Mode: p.mode, + } + ret = append(ret, rule) + } + if p.group != "" { + rule := &pathzpb.AuthorizationRule{ + Id: ruleId + "_group", + Principal: &pathzpb.AuthorizationRule_Group{Group: p.group}, + Path: path, + Action: p.action, + Mode: p.mode, + } + ret = append(ret, rule) + } + } + return ret, nil +} + +func getGroups(groups []*group) []*pathzpb.Group { + ret := []*pathzpb.Group{} + for _, g := range groups { + group := &pathzpb.Group{ + Name: g.name, + Users: []*pathzpb.User{}, + } + for _, m := range g.members { + group.Users = append(group.Users, &pathzpb.User{Name: m}) + } + ret = append(ret, group) + } + return ret +} + +func TestGnsiPathzPolicyConfigError(t *testing.T) { + rules := []*rule{ + &rule{ + path: "/a/b[k1=v1][k2=v2]/c", + user: "User1", + group: "Group1", + action: pathzpb.Action_ACTION_PERMIT, + mode: pathzpb.Mode_MODE_READ, + }, + &rule{ + path: "/a/b[k1=v1][k3=v3]/c", + user: "User1", + group: "Group1", + action: pathzpb.Action_ACTION_PERMIT, + mode: pathzpb.Mode_MODE_READ, + }, + } + groups := []*group{ + &group{ + name: "Group1", + members: []string{ + "User1", + "User2", + }, + }, + } + rs, err := getGnmiAuthzConfig(rules) + if err != nil { + t.Errorf("Error in getGnmiAuthzConfig: %v", err) + } + processor := &GnmiAuthzProcessor{} + err = processor.UpdatePolicyFromProto(&pathzpb.AuthorizationPolicy{ + Rules: rs, + Groups: getGroups(groups), + }) + if err == nil { + t.Errorf("Expect error in UpdatePolicyFromProto, got nil") + } +} + +func TestGnsiPathzPolicyChecker(t *testing.T) { + rules := []*rule{ + &rule{ + path: "/", + user: "User3", + action: pathzpb.Action_ACTION_PERMIT, + mode: pathzpb.Mode_MODE_READ, + }, + &rule{ + path: "/a/b/c", + user: "User1", + group: "Group1", + action: pathzpb.Action_ACTION_PERMIT, + mode: pathzpb.Mode_MODE_WRITE, + }, + &rule{ + path: "/a/d[k1=*]/e/f", + user: "User1", + action: pathzpb.Action_ACTION_PERMIT, + mode: pathzpb.Mode_MODE_READ, + }, + &rule{ + path: "/a/d[k1=*]/e/f", + group: "Group1", + action: pathzpb.Action_ACTION_DENY, + mode: pathzpb.Mode_MODE_READ, + }, + &rule{ + path: "/a/d[k1=v1]/e", + user: "User1", + group: "Group1", + action: pathzpb.Action_ACTION_PERMIT, + mode: pathzpb.Mode_MODE_READ, + }, + &rule{ + path: "/a/d[k1=v1]/e", + user: "User1", + action: pathzpb.Action_ACTION_DENY, + mode: pathzpb.Mode_MODE_READ, + }, + &rule{ + path: "/a/d[k1=*]/e/g[k2=v2]/h", + group: "Group1", + action: pathzpb.Action_ACTION_PERMIT, + mode: pathzpb.Mode_MODE_READ, + }, + &rule{ + path: "/a/d[k1=v1]/e/g[k2=*]/h", + group: "Group1", + action: pathzpb.Action_ACTION_DENY, + mode: pathzpb.Mode_MODE_READ, + }, + &rule{ + path: "/a/d[k1=*]/e/g[k2=v2]/h", + group: "Group2", + action: pathzpb.Action_ACTION_DENY, + mode: pathzpb.Mode_MODE_READ, + }, + &rule{ + path: "/a/i[k3=*][k4=v4]/j[k5=v5]/k/l", + user: "User1", + action: pathzpb.Action_ACTION_PERMIT, + mode: pathzpb.Mode_MODE_READ, + }, + &rule{ + path: "/a/i[k3=v3][k4=*]/j[k5=*]/k", + user: "User1", + action: pathzpb.Action_ACTION_DENY, + mode: pathzpb.Mode_MODE_READ, + }, + } + groups := []*group{ + &group{ + name: "Group1", + members: []string{ + "User1", + "User2", + }, + }, + &group{ + name: "Group2", + members: []string{ + "User1", + "User3", + }, + }, + } + rs, err := getGnmiAuthzConfig(rules) + if err != nil { + t.Errorf("Error in getGnmiAuthzConfig: %v", err) + } + processor := &GnmiAuthzProcessor{} + err = processor.UpdatePolicyFromProto(&pathzpb.AuthorizationPolicy{ + Rules: rs, + Groups: getGroups(groups), + }) + if err != nil { + t.Errorf("Error in UpdatePolicyFromProto: %v", err) + } + + for _, tc := range []struct { + description string + user string + prefix string + path string + mode pathzpb.Mode + error bool + result pathzpb.Action + matchedRuleId string + matchedRule string + }{ + { + description: "Undefined mode", + user: "User1", + path: "/a/b/c", + mode: pathzpb.Mode_MODE_UNSPECIFIED, + error: true, + }, + { + description: "No matched rule", + user: "User3", + path: "/a/b/c", + mode: pathzpb.Mode_MODE_WRITE, + error: false, + result: pathzpb.Action_ACTION_UNSPECIFIED, + }, + { + description: "No matched prefix rule", + user: "User3", + path: "/a/d[k1=v1]/e", + mode: pathzpb.Mode_MODE_WRITE, + error: false, + result: pathzpb.Action_ACTION_UNSPECIFIED, + }, + { + description: "Exact path match with no key", + user: "User1", + path: "/a/b/c", + mode: pathzpb.Mode_MODE_WRITE, + error: false, + result: pathzpb.Action_ACTION_PERMIT, + matchedRuleId: "/a/b/c_MODE_WRITE_ACTION_PERMIT_user", + matchedRule: "/a/b/c", + }, + { + description: "Group match", + user: "User2", + path: "/a/b/c", + mode: pathzpb.Mode_MODE_WRITE, + error: false, + result: pathzpb.Action_ACTION_PERMIT, + matchedRuleId: "/a/b/c_MODE_WRITE_ACTION_PERMIT_group", + matchedRule: "/a/b/c", + }, + { + description: "Prefix match", + user: "User1", + path: "/a/b/c/d[k1=v1]/e", + mode: pathzpb.Mode_MODE_WRITE, + error: false, + result: pathzpb.Action_ACTION_PERMIT, + matchedRuleId: "/a/b/c_MODE_WRITE_ACTION_PERMIT_user", + matchedRule: "/a/b/c", + }, + { + description: "Root match", + user: "User3", + path: "/a/b/c/d[k1=v1]/e", + mode: pathzpb.Mode_MODE_READ, + error: false, + result: pathzpb.Action_ACTION_PERMIT, + matchedRuleId: "/_MODE_READ_ACTION_PERMIT_user", + matchedRule: "/", + }, + { + description: "Root request", + user: "User1", + path: "/", + mode: pathzpb.Mode_MODE_READ, + error: false, + result: pathzpb.Action_ACTION_UNSPECIFIED, + }, + { + description: "Wildcard Key match", + user: "User2", + path: "/a/d[k1=v2]/e/f", + mode: pathzpb.Mode_MODE_READ, + error: false, + result: pathzpb.Action_ACTION_DENY, + matchedRuleId: "/a/d[k1=*]/e/f_MODE_READ_ACTION_DENY_group", + matchedRule: "/a/d[k1=*]/e/f", + }, + { + description: "User match", + user: "User1", + path: "/a/d[k1=v2]/e/f", + mode: pathzpb.Mode_MODE_READ, + error: false, + result: pathzpb.Action_ACTION_PERMIT, + matchedRuleId: "/a/d[k1=*]/e/f_MODE_READ_ACTION_PERMIT_user", + matchedRule: "/a/d[k1=*]/e/f", + }, + { + description: "Exact Key match", + user: "User2", + prefix: "/a", + path: "/d[k1=v1]/e/f", + mode: pathzpb.Mode_MODE_READ, + error: false, + result: pathzpb.Action_ACTION_PERMIT, + matchedRuleId: "/a/d[k1=v1]/e_MODE_READ_ACTION_PERMIT_group", + matchedRule: "/a/d[k1=v1]/e", + }, + { + description: "Deny overwrites permit", + user: "User1", + prefix: "/a", + path: "/d[k1=v1]/e/f", + mode: pathzpb.Mode_MODE_READ, + error: false, + result: pathzpb.Action_ACTION_DENY, + matchedRuleId: "/a/d[k1=v1]/e_MODE_READ_ACTION_DENY_user", + matchedRule: "/a/d[k1=v1]/e", + }, + { + description: "Exact key match on first key", + user: "User1", + prefix: "/a", + path: "/d[k1=v1]/e/g[k2=v2]/h", + mode: pathzpb.Mode_MODE_READ, + error: false, + result: pathzpb.Action_ACTION_DENY, + matchedRuleId: "/a/d[k1=v1]/e/g[k2=*]/h_MODE_READ_ACTION_DENY_group", + matchedRule: "/a/d[k1=v1]/e/g[k2=*]/h", + }, + { + description: "Deny overwrites permit in group", + user: "User1", + prefix: "/a", + path: "/d[k1=v2]/e/g[k2=v2]/h", + mode: pathzpb.Mode_MODE_READ, + error: false, + result: pathzpb.Action_ACTION_DENY, + matchedRuleId: "/a/d[k1=*]/e/g[k2=v2]/h_MODE_READ_ACTION_DENY_group", + matchedRule: "/a/d[k1=*]/e/g[k2=v2]/h", + }, + { + description: "Multiple key match", + user: "User1", + prefix: "/a", + path: "/i[k3=v3][k4=v4]/j[k5=v5]/k/l", + mode: pathzpb.Mode_MODE_READ, + error: false, + result: pathzpb.Action_ACTION_DENY, + matchedRuleId: "/a/i[k3=v3][k4=*]/j[k5=*]/k_MODE_READ_ACTION_DENY_user", + matchedRule: "/a/i[k3=v3][k4=*]/j[k5=*]/k", + }, + { + description: "Multiple key match wildcard", + user: "User1", + prefix: "/a", + path: "/i[k3=v4][k4=v4]/j[k5=v5]/k/l", + mode: pathzpb.Mode_MODE_READ, + error: false, + result: pathzpb.Action_ACTION_PERMIT, + matchedRuleId: "/a/i[k3=*][k4=v4]/j[k5=v5]/k/l_MODE_READ_ACTION_PERMIT_user", + matchedRule: "/a/i[k3=*][k4=v4]/j[k5=v5]/k/l", + }, + } { + t.Run(tc.description, func(t *testing.T) { + p, err := getGnmiPath(tc.path) + if err != nil { + t.Errorf("Error in getGnmiPath: %v", err) + } + var r *Result + if tc.prefix != "" { + var prefix *gnmipb.Path + prefix, err = getGnmiPath(tc.prefix) + if err != nil { + t.Errorf("Error in getGnmiPath: %v", err) + } + r, err = processor.AuthorizeWithPrefix(tc.user, prefix, p, tc.mode) + } else { + r, err = processor.Authorize(tc.user, p, tc.mode) + } + if tc.error != (err != nil) { + t.Errorf("Returned unexpected error: %v", err) + } + if !tc.error { + if tc.result != r.Action { + t.Errorf("Expect %v, got %v", tc.result, r.Action) + } + if tc.matchedRuleId != r.RuleId { + t.Errorf("Expect %v, got %v", tc.matchedRuleId, r.RuleId) + } + if tc.matchedRule != r.MatchedRule { + t.Errorf("Expect %v, got %v", tc.matchedRule, r.MatchedRule) + } + } + }) + } +} + +func TestGnsiPathzPolicyNil(t *testing.T) { + + t.Run("Authorize", func(t *testing.T) { + var p *GnmiAuthzProcessor + if _, err := p.Authorize("", nil, pathzpb.Mode_MODE_UNSPECIFIED); err == nil { + t.Error("expected error in Authorize") + } + }) + + t.Run("AuthorizeWithPrefix", func(t *testing.T) { + var p *GnmiAuthzProcessor + if _, err := p.AuthorizeWithPrefix("", nil, nil, pathzpb.Mode_MODE_UNSPECIFIED); err == nil { + t.Error("expected error in AuthorizeWithPrefix") + } + }) + + t.Run("UpdatePolicyFromFile", func(t *testing.T) { + var p *GnmiAuthzProcessor + if err := p.UpdatePolicyFromFile(""); err == nil { + t.Error("expected error in UpdatePolicyFromFile") + } + }) + + t.Run("UpdatePolicyFromProto", func(t *testing.T) { + var p *GnmiAuthzProcessor + if err := p.UpdatePolicyFromProto(nil); err == nil { + t.Error("expected error in UpdatePolicyFromProto") + } + }) + + t.Run("GetPolicy", func(t *testing.T) { + var p *GnmiAuthzProcessor + if err := p.GetPolicy(); err != nil { + t.Error("expected nil policy") + } + }) + + t.Run("insertPath", func(t *testing.T) { + var n *gnmiAuthzNode + if err := n.insertPath(nil, 0, nil, 0); err == nil { + t.Error("expected error") + } + }) + + t.Run("authorize", func(t *testing.T) { + var n *gnmiAuthzNode + if res := n.authorize("", nil, pathzpb.Mode_MODE_UNSPECIFIED, 0, nil, 0, nil); !reflect.DeepEqual(res, Result{Action: pathzpb.Action_ACTION_UNSPECIFIED}) { + t.Errorf("expected unspecified; got: %#v", res) + } + }) + + t.Run("updatePermission", func(t *testing.T) { + var p *permission + if err := p.updatePermission(pathzpb.Action_ACTION_UNSPECIFIED, pathzpb.Mode_MODE_UNSPECIFIED, ""); err == nil { + t.Error("expected error") + } + }) + +} diff --git a/testdata/gnsi/pathz-version.json b/testdata/gnsi/pathz-version.json new file mode 100644 index 000000000..e11cf5bbe --- /dev/null +++ b/testdata/gnsi/pathz-version.json @@ -0,0 +1 @@ +{"pathz_version":"1762857207276163735","pathz_created_on":"1762857207276168472"} diff --git a/testdata/gnsi/pathz_policy.pb.txt b/testdata/gnsi/pathz_policy.pb.txt new file mode 100644 index 000000000..499b0e217 --- /dev/null +++ b/testdata/gnsi/pathz_policy.pb.txt @@ -0,0 +1,17 @@ +rules: < + id: "Rule1" + user: "User1" + path: < + > + action: ACTION_PERMIT + mode: MODE_READ +> +groups: < + name: "Group1" + users: < + name: "User1" + > + users: < + name: "User2" + > +>