From 4a7e8ca1fd0f474c353723b6902e5862732247be Mon Sep 17 00:00:00 2001 From: James Chacon Date: Mon, 11 Apr 2022 14:23:47 -0700 Subject: [PATCH] Client side OPA policy implementation (#98) * WIP * Add support for client side OPA checks. In practice this mostly just means "did I connect to a peer holding a cert I expect?" but in theory anything could be plumbed in. A local client can't really stop the user so this is more about trust about the remote server being as expected. * Add logging so it's possible to debug opa --- auth/opa/rpcauth/rpcauth.go | 64 +++++++++++++- auth/opa/rpcauth/rpcauth_test.go | 137 +++++++++++++++++++++++++----- cmd/proxy-server/main.go | 18 ++-- cmd/proxy-server/server/server.go | 18 +++- cmd/sanssh/client/client.go | 21 ++++- cmd/sanssh/main.go | 40 ++++++--- testing/integrate.sh | 29 +++++++ 7 files changed, 283 insertions(+), 44 deletions(-) diff --git a/auth/opa/rpcauth/rpcauth.go b/auth/opa/rpcauth/rpcauth.go index 5ed42f36..0e3823ba 100644 --- a/auth/opa/rpcauth/rpcauth.go +++ b/auth/opa/rpcauth/rpcauth.go @@ -53,6 +53,8 @@ type RPCAuthzHook interface { // New creates a new Authorizer from an opa.AuthzPolicy. Any supplied authorization // hooks will be executed, in the order provided, on each policy evauluation. +// NOTE: The policy is used for both client and server hooks below. If you need +// distinct policy for client vs server, create 2 Authorizer's. func New(policy *opa.AuthzPolicy, authzHooks ...RPCAuthzHook) *Authorizer { return &Authorizer{policy: policy, hooks: authzHooks} } @@ -60,6 +62,8 @@ func New(policy *opa.AuthzPolicy, authzHooks ...RPCAuthzHook) *Authorizer { // NewWithPolicy creates a new Authorizer from a policy string. Any supplied // authorization hooks will be executed, in the order provided, on each policy // evaluation. +// NOTE: The policy is used for both client and server hooks below. If you need +// distinct policy for client vs server, create 2 Authorizer's. func NewWithPolicy(ctx context.Context, policy string, authzHooks ...RPCAuthzHook) (*Authorizer, error) { p, err := opa.NewAuthzPolicy(ctx, policy) if err != nil { @@ -120,7 +124,7 @@ func (g *Authorizer) Eval(ctx context.Context, input *RPCAuthInput) error { func (g *Authorizer) Authorize(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { msg, ok := req.(proto.Message) if !ok { - return nil, status.Errorf(codes.Internal, "unable to authorize request of type %T, which is not proto.Message", req) + return nil, status.Errorf(codes.Internal, "unable to authorize request of type %T which is not proto.Message", req) } authInput, err := NewRPCAuthInput(ctx, info.FullMethod, msg) if err != nil { @@ -132,7 +136,61 @@ func (g *Authorizer) Authorize(ctx context.Context, req interface{}, info *grpc. return handler(ctx, req) } -// AuthorizeStream implements grpc.StreamServerInterceptor +// AuthorizeClient implements grpc.UnaryClientInterceptor +func (g *Authorizer) AuthorizeClient(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + msg, ok := req.(proto.Message) + if !ok { + return status.Errorf(codes.Internal, "unable to authorize request of type %T which is not proto.Message", req) + } + authInput, err := NewRPCAuthInput(ctx, method, msg) + if err != nil { + return status.Errorf(codes.Internal, "unable to create auth input: %v", err) + } + if err := g.Eval(ctx, authInput); err != nil { + return err + } + return invoker(ctx, method, req, reply, cc, opts...) +} + +// AuthorizeClientStream implements grpc.StreamClientInterceptor and applies policy checks on any SendMsg calls. +func (g *Authorizer) AuthorizeClientStream(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { + clientStream, err := streamer(ctx, desc, cc, method, opts...) + if err != nil { + return nil, status.Errorf(codes.Internal, "can't create clientStream: %v", err) + } + wrapped := &wrappedClientStream{ + ClientStream: clientStream, + method: method, + authz: g, + } + return wrapped, nil +} + +// wrappedClientStream wraps an existing grpc.ClientStream with authorization checking. +type wrappedClientStream struct { + grpc.ClientStream + method string + authz *Authorizer +} + +// see: grpc.ClientStream.SendMsg +func (e *wrappedClientStream) SendMsg(req interface{}) error { + ctx := e.Context() + msg, ok := req.(proto.Message) + if !ok { + return status.Errorf(codes.Internal, "unable to authorize request of type %T which is not proto.Message", req) + } + authInput, err := NewRPCAuthInput(ctx, e.method, msg) + if err != nil { + return err + } + if err := e.authz.Eval(ctx, authInput); err != nil { + return err + } + return e.ClientStream.SendMsg(req) +} + +// AuthorizeStream implements grpc.StreamServerInterceptor and applies policy checks on any RecvMsg calls. func (g *Authorizer) AuthorizeStream(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { wrapped := &wrappedStream{ ServerStream: ss, @@ -161,7 +219,7 @@ func (e *wrappedStream) RecvMsg(req interface{}) error { } msg, ok := req.(proto.Message) if !ok { - return status.Errorf(codes.Internal, "unable to authorize request of type %T, which is not proto.Message", req) + return status.Errorf(codes.Internal, "unable to authorize request of type %T which is not proto.Message", req) } authInput, err := NewRPCAuthInput(ctx, e.info.FullMethod, msg) if err != nil { diff --git a/auth/opa/rpcauth/rpcauth_test.go b/auth/opa/rpcauth/rpcauth_test.go index 3fa3660d..243eae30 100644 --- a/auth/opa/rpcauth/rpcauth_test.go +++ b/auth/opa/rpcauth/rpcauth_test.go @@ -363,9 +363,10 @@ func TestAuthzHook(t *testing.T) { } func TestNewWithPolicy(t *testing.T) { - _, err := NewWithPolicy(context.Background(), policyString) + ctx := context.Background() + _, err := NewWithPolicy(ctx, policyString) testutil.FatalOnErr("NewWithPolicy valid", err, t) - if _, err := NewWithPolicy(context.Background(), ""); err == nil { + if _, err := NewWithPolicy(ctx, ""); err == nil { t.Error("didn't get error for empty policy") } } @@ -386,6 +387,8 @@ func TestRpcAuthInput(t *testing.T) { tcp, err := net.ResolveTCPAddr("tcp4", "127.0.0.1:1") testutil.FatalOnErr("ResolveIPAddr", err, t) + ctx := context.Background() + for _, tc := range []struct { name string ctx context.Context @@ -395,7 +398,7 @@ func TestRpcAuthInput(t *testing.T) { }{ { name: "method only", - ctx: context.Background(), + ctx: ctx, method: "/AMethod", compare: &RPCAuthInput{ Method: "/AMethod", @@ -404,7 +407,7 @@ func TestRpcAuthInput(t *testing.T) { }, { name: "method and metadata", - ctx: metadata.NewIncomingContext(context.Background(), md), + ctx: metadata.NewIncomingContext(ctx, md), method: "/AMethod", compare: &RPCAuthInput{ Method: "/AMethod", @@ -414,7 +417,7 @@ func TestRpcAuthInput(t *testing.T) { }, { name: "method and request", - ctx: context.Background(), + ctx: ctx, method: "/AMethod", req: &emptypb.Empty{}, compare: &RPCAuthInput{ @@ -426,7 +429,7 @@ func TestRpcAuthInput(t *testing.T) { }, { name: "method and a peer context but no addr", - ctx: peer.NewContext(context.Background(), &peer.Peer{}), + ctx: peer.NewContext(ctx, &peer.Peer{}), method: "/AMethod", compare: &RPCAuthInput{ Method: "/AMethod", @@ -435,7 +438,7 @@ func TestRpcAuthInput(t *testing.T) { }, { name: "method and a peer context", - ctx: peer.NewContext(context.Background(), &peer.Peer{ + ctx: peer.NewContext(ctx, &peer.Peer{ Addr: tcp, }), method: "/AMethod", @@ -452,7 +455,7 @@ func TestRpcAuthInput(t *testing.T) { }, { name: "method and a peer context with non tls auth", - ctx: peer.NewContext(context.Background(), &peer.Peer{ + ctx: peer.NewContext(ctx, &peer.Peer{ Addr: tcp, AuthInfo: testAuthInfo{}, }), @@ -471,7 +474,7 @@ func TestRpcAuthInput(t *testing.T) { }, { name: "method and a peer context with tls auth", - ctx: peer.NewContext(context.Background(), &peer.Peer{ + ctx: peer.NewContext(ctx, &peer.Peer{ Addr: tcp, AuthInfo: credentials.TLSInfo{ SPIFFEID: &url.URL{ @@ -514,36 +517,73 @@ func TestAuthorize(t *testing.T) { FullMethod: "/Foo/Bar", } gotCalled := false - handler := func(ctx context.Context, req interface{}) (resp interface{}, err error) { + handler := func(context.Context, interface{}) (interface{}, error) { gotCalled = true - return + return nil, nil } + ctx := context.Background() - authorizer, err := NewWithPolicy(context.Background(), policyString) + authorizer, err := NewWithPolicy(ctx, policyString) testutil.FatalOnErr("NewWithPolicy", err, t) // Should fail on no proto.Message - _, err = authorizer.Authorize(context.Background(), nil, info, handler) + _, err = authorizer.Authorize(ctx, nil, info, handler) testutil.FatalOnNoErr("not a proto message", err, t) // Should function normally - _, err = authorizer.Authorize(context.Background(), req, info, handler) + _, err = authorizer.Authorize(ctx, req, info, handler) testutil.FatalOnErr("Authorize", err, t) if !gotCalled { t.Fatal("never called handler") } // Create one with a hook so we can fail. - authorizer, err = NewWithPolicy(context.Background(), policyString, RPCAuthzHookFunc(func(ctx context.Context, input *RPCAuthInput) error { + authorizer, err = NewWithPolicy(ctx, policyString, RPCAuthzHookFunc(func(context.Context, *RPCAuthInput) error { return status.Error(codes.FailedPrecondition, "hook failed") })) testutil.FatalOnErr("NewWithPolicy with hooks", err, t) // This time it should fail due to the hook. - _, err = authorizer.Authorize(context.Background(), req, info, handler) + _, err = authorizer.Authorize(ctx, req, info, handler) testutil.FatalOnNoErr("Authorize with failing hook", err, t) } +func TestAuthorizeClient(t *testing.T) { + req := &emptypb.Empty{} + method := "/Foo/Bar" + + gotCalled := false + invoker := func(context.Context, string, interface{}, interface{}, *grpc.ClientConn, ...grpc.CallOption) error { + gotCalled = true + return nil + } + ctx := context.Background() + + authorizer, err := NewWithPolicy(ctx, policyString) + testutil.FatalOnErr("NewWithPolicy", err, t) + + // Should fail on no proto.Message + err = authorizer.AuthorizeClient(ctx, method, nil, nil, nil, invoker) + testutil.FatalOnNoErr("not a proto message", err, t) + + // Should function normally + err = authorizer.AuthorizeClient(ctx, method, req, nil, nil, invoker) + testutil.FatalOnErr("AuthorizeClient", err, t) + if !gotCalled { + t.Fatal("never called invoker") + } + + // Create one with a hook so we can fail. + authorizer, err = NewWithPolicy(ctx, policyString, RPCAuthzHookFunc(func(context.Context, *RPCAuthInput) error { + return status.Error(codes.FailedPrecondition, "hook failed") + })) + testutil.FatalOnErr("NewWithPolicy with hooks", err, t) + + // This time it should fail due to the hook. + err = authorizer.AuthorizeClient(ctx, method, req, nil, nil, invoker) + testutil.FatalOnNoErr("AuthorizeClient with failing hook", err, t) +} + type fakeServerStream struct { testutil.FakeServerStream Ctx context.Context @@ -557,26 +597,35 @@ func (f *fakeServerStream) Context() context.Context { return f.Ctx } +type fakeClientStream struct { + testutil.FakeClientStream +} + +func (*fakeClientStream) SendMsg(req interface{}) error { + return nil +} + func TestAuthorizeStream(t *testing.T) { req := &emptypb.Empty{} info := &grpc.StreamServerInfo{ FullMethod: "/Foo/Bar", } + ctx := context.Background() handler := func(srv interface{}, stream grpc.ServerStream) error { return stream.RecvMsg(srv) } - fake := &fakeServerStream{Ctx: context.Background()} + fake := &fakeServerStream{Ctx: ctx} - authorizer, err := NewWithPolicy(context.Background(), policyString) + authorizer, err := NewWithPolicy(ctx, policyString) testutil.FatalOnErr("NewWithPolicy", err, t) err = authorizer.AuthorizeStream(req, fake, info, handler) testutil.FatalOnErr("AuthorizeStream", err, t) // Should fail with a normal fake server - err = authorizer.AuthorizeStream(req, &testutil.FakeServerStream{Ctx: context.Background()}, info, handler) + err = authorizer.AuthorizeStream(req, &testutil.FakeServerStream{Ctx: ctx}, info, handler) testutil.FatalOnNoErr("AuthorizeStream failing RecvMsg", err, t) // Should fail on a non proto.Message request @@ -584,7 +633,7 @@ func TestAuthorizeStream(t *testing.T) { testutil.FatalOnNoErr("AuthorizeStream non proto", err, t) // Create one with a hook so we can fail. - authorizer, err = NewWithPolicy(context.Background(), policyString, RPCAuthzHookFunc(func(ctx context.Context, input *RPCAuthInput) error { + authorizer, err = NewWithPolicy(ctx, policyString, RPCAuthzHookFunc(func(context.Context, *RPCAuthInput) error { return status.Error(codes.FailedPrecondition, "hook failed") })) testutil.FatalOnErr("NewWithPolicy with hooks", err, t) @@ -593,3 +642,51 @@ func TestAuthorizeStream(t *testing.T) { err = authorizer.AuthorizeStream(req, fake, info, handler) testutil.FatalOnNoErr("AuthorizeStream with failing hook", err, t) } + +func TestAuthorizeClientStream(t *testing.T) { + req := &emptypb.Empty{} + method := "/Foo/Bar" + desc := &grpc.StreamDesc{ + StreamName: "/Bar", + } + + ctx := context.Background() + + streamer := func(context.Context, *grpc.StreamDesc, *grpc.ClientConn, string, ...grpc.CallOption) (grpc.ClientStream, error) { + return &fakeClientStream{}, nil + } + + badStreamer := func(context.Context, *grpc.StreamDesc, *grpc.ClientConn, string, ...grpc.CallOption) (grpc.ClientStream, error) { + return nil, errors.New("error") + } + + authorizer, err := NewWithPolicy(ctx, policyString) + testutil.FatalOnErr("NewWithPolicy", err, t) + + // Test bad streamer returns error + _, err = authorizer.AuthorizeClientStream(ctx, desc, nil, method, badStreamer) + testutil.FatalOnNoErr("AuthorizeClientStream with bad streamer", err, t) + + stream, err := authorizer.AuthorizeClientStream(ctx, desc, nil, method, streamer) + testutil.FatalOnErr("AuthorizeClientStream", err, t) + + // Should fail on a non proto.Message request + err = stream.SendMsg(nil) + testutil.FatalOnNoErr("AuthorizeClientStream.SendMsg non proto", err, t) + + // Should work with a basic msg + err = stream.SendMsg(req) + testutil.FatalOnErr("AuthorizeClientStream real request", err, t) + + // Create one with a hook so we can fail. + authorizer, err = NewWithPolicy(ctx, policyString, RPCAuthzHookFunc(func(context.Context, *RPCAuthInput) error { + return status.Error(codes.FailedPrecondition, "hook failed") + })) + testutil.FatalOnErr("NewWithPolicy with hooks", err, t) + + // This time it should fail due to the hook. + stream, err = authorizer.AuthorizeClientStream(ctx, desc, nil, method, streamer) + testutil.FatalOnErr("AuthorizeClientStream with failing hook setup", err, t) + err = stream.SendMsg(req) + testutil.FatalOnNoErr("AuthorizeClientStream with failing hook", err, t) +} diff --git a/cmd/proxy-server/main.go b/cmd/proxy-server/main.go index b93d2a4e..117fc29e 100644 --- a/cmd/proxy-server/main.go +++ b/cmd/proxy-server/main.go @@ -49,13 +49,15 @@ var ( //go:embed default-policy.rego defaultPolicy string - policyFlag = flag.String("policy", defaultPolicy, "Local OPA policy governing access. If empty, use builtin policy.") - policyFile = flag.String("policy-file", "", "Path to a file with an OPA policy. If empty, uses --policy.") - hostport = flag.String("hostport", "localhost:50043", "Where to listen for connections.") - credSource = flag.String("credential-source", mtlsFlags.Name(), fmt.Sprintf("Method used to obtain mTLS creds (one of [%s])", strings.Join(mtls.Loaders(), ","))) - verbosity = flag.Int("v", 0, "Verbosity level. > 0 indicates more extensive logging") - validate = flag.Bool("validate", false, "If true will evaluate the policy and then exit (non-zero on error)") - justification = flag.Bool("justification", false, "If true then justification (which is logged and possibly validated) must be passed along in the client context Metadata with the key '"+rpcauth.ReqJustKey+"'") + policyFlag = flag.String("policy", defaultPolicy, "Local OPA policy governing access. If empty, use builtin policy.") + policyFile = flag.String("policy-file", "", "Path to a file with an OPA policy. If empty, uses --policy.") + clientPolicyFlag = flag.String("client-policy", "", "OPA policy for outbound client actions (i.e. connecting to sansshell servers). If empty no policy is applied.") + clientPolicyFile = flag.String("client-policy-file", "", "Path to a file with a client OPA. If empty uses --client-policy") + hostport = flag.String("hostport", "localhost:50043", "Where to listen for connections.") + credSource = flag.String("credential-source", mtlsFlags.Name(), fmt.Sprintf("Method used to obtain mTLS creds (one of [%s])", strings.Join(mtls.Loaders(), ","))) + verbosity = flag.Int("v", 0, "Verbosity level. > 0 indicates more extensive logging") + validate = flag.Bool("validate", false, "If true will evaluate the policy and then exit (non-zero on error)") + justification = flag.Bool("justification", false, "If true then justification (which is logged and possibly validated) must be passed along in the client context Metadata with the key '"+rpcauth.ReqJustKey+"'") ) func main() { @@ -66,6 +68,7 @@ func main() { stdr.SetVerbosity(*verbosity) policy := util.ChoosePolicy(logger, defaultPolicy, *policyFlag, *policyFile) + clientPolicy := util.ChoosePolicy(logger, "", *clientPolicyFlag, *clientPolicyFile) ctx := logr.NewContext(context.Background(), logger) if *validate { @@ -80,6 +83,7 @@ func main() { rs := server.RunState{ Logger: logger, Policy: policy, + ClientPolicy: clientPolicy, CredSource: *credSource, Hostport: *hostport, Justification: *justification, diff --git a/cmd/proxy-server/server/server.go b/cmd/proxy-server/server/server.go index aa08dacc..035ffe8c 100644 --- a/cmd/proxy-server/server/server.go +++ b/cmd/proxy-server/server/server.go @@ -42,6 +42,8 @@ type RunState struct { Logger logr.Logger // Policy is an OPA policy for determining authz decisions. Policy string + // ClientPolicy is an optional OPA policy for determining outbound decisions. + ClientPolicy string // CredSource is a registered credential source with the mtls package. CredSource string // Hostport is the host:port to run the server. @@ -92,9 +94,23 @@ func Run(ctx context.Context, rs RunState, hooks ...rpcauth.RPCAuthzHook) { os.Exit(1) } + var clientAuthz *rpcauth.Authorizer + if rs.ClientPolicy != "" { + clientAuthz, err = rpcauth.NewWithPolicy(ctx, rs.ClientPolicy) + if err != nil { + rs.Logger.Error(err, "client rpcauth.NewWithPolicy") + } + } + + // We always have the logger but might need to chain if we're also doing client OPA checks. + intOp := grpc.WithStreamInterceptor(telemetry.StreamClientLogInterceptor(rs.Logger)) + if clientAuthz != nil { + intOp = grpc.WithChainStreamInterceptor(telemetry.StreamClientLogInterceptor(rs.Logger), clientAuthz.AuthorizeClientStream) + } + dialOpts := []grpc.DialOption{ grpc.WithTransportCredentials(clientCreds), - grpc.WithStreamInterceptor(telemetry.StreamClientLogInterceptor(rs.Logger)), + intOp, } targetDialer := server.NewDialer(dialOpts...) diff --git a/cmd/sanssh/client/client.go b/cmd/sanssh/client/client.go index 43fe16d1..2aeb9892 100644 --- a/cmd/sanssh/client/client.go +++ b/cmd/sanssh/client/client.go @@ -27,6 +27,7 @@ import ( "time" "github.com/Snowflake-Labs/sansshell/auth/mtls" + "github.com/Snowflake-Labs/sansshell/auth/opa/rpcauth" "github.com/Snowflake-Labs/sansshell/proxy/proxy" "github.com/google/subcommands" "google.golang.org/grpc" @@ -60,6 +61,8 @@ type RunState struct { CredSource string // Timeout is the duration to place on the context when making RPC calls. Timeout time.Duration + // ClientPolicy is an optional OPA policy for determining outbound decisions. + ClientPolicy string } const ( @@ -129,8 +132,24 @@ func Run(ctx context.Context, rs RunState) { os.Exit(1) } + var clientAuthz *rpcauth.Authorizer + if rs.ClientPolicy != "" { + clientAuthz, err = rpcauth.NewWithPolicy(ctx, rs.ClientPolicy) + if err != nil { + fmt.Fprintf(os.Stderr, "Could not load policy: %v\n", err) + } + } + + // We may need an option for doing client OPA checks. + ops := []grpc.DialOption{ + grpc.WithTransportCredentials(creds), + } + if clientAuthz != nil { + ops = append(ops, grpc.WithStreamInterceptor(clientAuthz.AuthorizeClientStream)) + } + // Set up a connection to the sansshell-server (possibly via proxy). - conn, err := proxy.Dial(rs.Proxy, rs.Targets, grpc.WithTransportCredentials(creds)) + conn, err := proxy.Dial(rs.Proxy, rs.Targets, ops...) if err != nil { fmt.Fprintf(os.Stderr, "Could not connect to proxy %q node(s) %v: %v\n", rs.Proxy, rs.Targets, err) os.Exit(1) diff --git a/cmd/sanssh/main.go b/cmd/sanssh/main.go index c6f9c5ac..75daa28b 100644 --- a/cmd/sanssh/main.go +++ b/cmd/sanssh/main.go @@ -31,7 +31,10 @@ import ( mtlsFlags "github.com/Snowflake-Labs/sansshell/auth/mtls/flags" "github.com/Snowflake-Labs/sansshell/auth/opa/rpcauth" "github.com/Snowflake-Labs/sansshell/cmd/sanssh/client" + cmdUtil "github.com/Snowflake-Labs/sansshell/cmd/util" "github.com/Snowflake-Labs/sansshell/services/util" + "github.com/go-logr/logr" + "github.com/go-logr/stdr" "github.com/google/subcommands" "google.golang.org/grpc/metadata" @@ -60,11 +63,14 @@ var ( %s in the environment can also be set instead of setting this flag. The flag will take precedence. If blank a direct connection to the first entry in --targets will be made. If port is blank the default of %d will be used`, proxyEnv, defaultProxyPort)) - timeout = flag.Duration("timeout", defaultTimeout, "How long to wait for the command to complete") - credSource = flag.String("credential-source", mtlsFlags.Name(), fmt.Sprintf("Method used to obtain mTLS credentials (one of [%s])", strings.Join(mtls.Loaders(), ","))) - outputsDir = flag.String("output-dir", "", "If set defines a directory to emit output/errors from commands. Files will be generated based on target as destination/0 destination/0.error, etc.") - justification = flag.String("justification", "", "If non-empty will add the key '"+rpcauth.ReqJustKey+"' to the outgoing context Metadata to be passed along to the server for possible validation and logging.") - targetsFile = flag.String("targets-file", "", "If set read the targets list line by line (as host[:port]) from the indicated file instead of using --targets (error if both flags are used). A blank port acts the same as --targets") + timeout = flag.Duration("timeout", defaultTimeout, "How long to wait for the command to complete") + credSource = flag.String("credential-source", mtlsFlags.Name(), fmt.Sprintf("Method used to obtain mTLS credentials (one of [%s])", strings.Join(mtls.Loaders(), ","))) + outputsDir = flag.String("output-dir", "", "If set defines a directory to emit output/errors from commands. Files will be generated based on target as destination/0 destination/0.error, etc.") + justification = flag.String("justification", "", "If non-empty will add the key '"+rpcauth.ReqJustKey+"' to the outgoing context Metadata to be passed along to the server for possible validation and logging.") + targetsFile = flag.String("targets-file", "", "If set read the targets list line by line (as host[:port]) from the indicated file instead of using --targets (error if both flags are used). A blank port acts the same as --targets") + clientPolicyFlag = flag.String("client-policy", "", "OPA policy for outbound client actions. If empty no policy is applied.") + clientPolicyFile = flag.String("client-policy-file", "", "Path to a file with a client OPA. If empty uses --client-policy") + verbosity = flag.Int("v", 0, "Verbosity level. > 0 indicates more extensive logging") // targets will be bound to --targets for sending a single request to N nodes. targetsFlag util.StringSliceFlag @@ -91,6 +97,9 @@ func init() { subcommands.ImportantFlag("output-dir") subcommands.ImportantFlag("targets-file") subcommands.ImportantFlag("justification") + subcommands.ImportantFlag("client-policy") + subcommands.ImportantFlag("client-policy-file") + subcommands.ImportantFlag("v") } func main() { @@ -130,16 +139,23 @@ func main() { (*targetsFlag.Target)[i] = fmt.Sprintf("%s:%d", t, defaultTargetPort) } } + clientPolicy := cmdUtil.ChoosePolicy(logr.Discard(), "", *clientPolicyFlag, *clientPolicyFile) + + logOpts := log.Ldate | log.Ltime | log.Lshortfile + logger := stdr.New(log.New(os.Stderr, "", logOpts)).WithName("sanssh") + stdr.SetVerbosity(*verbosity) rs := client.RunState{ - Proxy: *proxyAddr, - Targets: *targetsFlag.Target, - Outputs: *outputsFlag.Target, - OutputsDir: *outputsDir, - CredSource: *credSource, - Timeout: *timeout, + Proxy: *proxyAddr, + Targets: *targetsFlag.Target, + Outputs: *outputsFlag.Target, + OutputsDir: *outputsDir, + CredSource: *credSource, + Timeout: *timeout, + ClientPolicy: clientPolicy, } - ctx := context.Background() + ctx := logr.NewContext(context.Background(), logger) + if *justification != "" { ctx = metadata.AppendToOutgoingContext(ctx, rpcauth.ReqJustKey, *justification) } diff --git a/testing/integrate.sh b/testing/integrate.sh index 2d052977..6df51a14 100755 --- a/testing/integrate.sh +++ b/testing/integrate.sh @@ -540,6 +540,35 @@ if [ $? != 1 ]; then check_status 1 /dev/null missing justification failed fi +cat > ${LOGS}/client-policy.rego < ${LOGS}/client-policy.rego <