diff --git a/agent/consul/enterprise_server_oss.go b/agent/consul/enterprise_server_oss.go index 0ee39c9245..e2ed043714 100644 --- a/agent/consul/enterprise_server_oss.go +++ b/agent/consul/enterprise_server_oss.go @@ -3,7 +3,9 @@ package consul import ( + "errors" "net" + "strings" "github.com/hashicorp/consul/agent/pool" "github.com/hashicorp/consul/agent/structs" @@ -59,6 +61,18 @@ func (s *Server) validateEnterpriseRequest(entMeta *structs.EnterpriseMeta, writ return nil } +func (s *Server) validateEnterpriseIntentionNamespace(ns string, _ bool) error { + if ns == "" { + return nil + } else if strings.ToLower(ns) == structs.IntentionDefaultNamespace { + return nil + } + + // No special handling for wildcard namespaces as they are pointless in OSS. + + return errors.New("Namespaces is a Consul Enterprise feature") +} + func (_ *Server) addEnterpriseSerfTags(_ map[string]string) { // do nothing } diff --git a/agent/consul/fsm/snapshot_oss_test.go b/agent/consul/fsm/snapshot_oss_test.go index 800e7aadfa..59c18f2222 100644 --- a/agent/consul/fsm/snapshot_oss_test.go +++ b/agent/consul/fsm/snapshot_oss_test.go @@ -576,7 +576,7 @@ func TestFSM_SnapshotRestore_OSS(t *testing.T) { require.Equal(t, autopilotConf, restoredConf) // Verify intentions are restored. - _, ixns, err := fsm2.state.Intentions(nil) + _, ixns, err := fsm2.state.Intentions(nil, structs.WildcardEnterpriseMeta()) require.NoError(t, err) require.Len(t, ixns, 1) require.Equal(t, ixn, ixns[0]) diff --git a/agent/consul/intention_endpoint.go b/agent/consul/intention_endpoint.go index 3c1764ceb1..a030b4e9e9 100644 --- a/agent/consul/intention_endpoint.go +++ b/agent/consul/intention_endpoint.go @@ -76,6 +76,10 @@ func (s *Intention) prepareApplyCreate(ident structs.ACLIdentity, authz acl.Auth args.Intention.DefaultNamespaces(entMeta) + if err := s.validateEnterpriseIntention(args.Intention); err != nil { + return err + } + // Validate. We do not validate on delete since it is valid to only // send an ID in that case. // Set the precedence @@ -135,11 +139,15 @@ func (s *Intention) prepareApplyUpdate(ident structs.ACLIdentity, authz acl.Auth args.Intention.DefaultNamespaces(entMeta) - // Validate. We do not validate on delete since it is valid to only - // send an ID in that case. + if err := s.validateEnterpriseIntention(args.Intention); err != nil { + return err + } + // Set the precedence args.Intention.UpdatePrecedence() + // Validate. We do not validate on delete since it is valid to only + // send an ID in that case. if err := args.Intention.Validate(); err != nil { return err } @@ -249,11 +257,44 @@ func (s *Intention) Get( return err } + // Get the ACL token for the request for the checks below. + var entMeta structs.EnterpriseMeta + if _, err := s.srv.ResolveTokenAndDefaultMeta(args.Token, &entMeta, nil); err != nil { + return err + } + + if args.Exact != nil { + // // Finish defaulting the namespace fields. + if args.Exact.SourceNS == "" { + args.Exact.SourceNS = entMeta.NamespaceOrDefault() + } + if err := s.srv.validateEnterpriseIntentionNamespace(args.Exact.SourceNS, true); err != nil { + return fmt.Errorf("Invalid SourceNS %q: %v", args.Exact.SourceNS, err) + } + + if args.Exact.DestinationNS == "" { + args.Exact.DestinationNS = entMeta.NamespaceOrDefault() + } + if err := s.srv.validateEnterpriseIntentionNamespace(args.Exact.DestinationNS, true); err != nil { + return fmt.Errorf("Invalid DestinationNS %q: %v", args.Exact.DestinationNS, err) + } + } + return s.srv.blockingQuery( &args.QueryOptions, &reply.QueryMeta, func(ws memdb.WatchSet, state *state.Store) error { - index, ixn, err := state.IntentionGet(ws, args.IntentionID) + var ( + index uint64 + ixn *structs.Intention + err error + ) + if args.IntentionID != "" { + index, ixn, err = state.IntentionGet(ws, args.IntentionID) + } else if args.Exact != nil { + index, ixn, err = state.IntentionGetExact(ws, args.Exact) + } + if err != nil { return err } @@ -296,10 +337,19 @@ func (s *Intention) List( return err } + var authzContext acl.AuthorizerContext + if _, err := s.srv.ResolveTokenAndDefaultMeta(args.Token, &args.EnterpriseMeta, &authzContext); err != nil { + return err + } + + if err := s.srv.validateEnterpriseRequest(&args.EnterpriseMeta, false); err != nil { + return err + } + return s.srv.blockingQuery( &args.QueryOptions, &reply.QueryMeta, func(ws memdb.WatchSet, state *state.Store) error { - index, ixns, err := state.Intentions(ws) + index, ixns, err := state.Intentions(ws, &args.EnterpriseMeta) if err != nil { return err } @@ -334,12 +384,24 @@ func (s *Intention) Match( } // Get the ACL token for the request for the checks below. - rule, err := s.srv.ResolveToken(args.Token) + var entMeta structs.EnterpriseMeta + authz, err := s.srv.ResolveTokenAndDefaultMeta(args.Token, &entMeta, nil) if err != nil { return err } - if rule != nil { + // Finish defaulting the namespace fields. + for i := range args.Match.Entries { + if args.Match.Entries[i].Namespace == "" { + args.Match.Entries[i].Namespace = entMeta.NamespaceOrDefault() + } + if err := s.srv.validateEnterpriseIntentionNamespace(args.Match.Entries[i].Namespace, true); err != nil { + return fmt.Errorf("Invalid match entry namespace %q: %v", + args.Match.Entries[i].Namespace, err) + } + } + + if authz != nil { var authzContext acl.AuthorizerContext // Go through each entry to ensure we have intention:read for the resource. @@ -351,7 +413,7 @@ func (s *Intention) Match( // matching, if you have it on the dest then perform a dest type match. for _, entry := range args.Match.Entries { entry.FillAuthzContext(&authzContext) - if prefix := entry.Name; prefix != "" && rule.IntentionRead(prefix, &authzContext) != acl.Allow { + if prefix := entry.Name; prefix != "" && authz.IntentionRead(prefix, &authzContext) != acl.Allow { accessorID := s.aclAccessorID(args.Token) // todo(kit) Migrate intention access denial logging over to audit logging when we implement it s.logger.Warn("Operation on intention prefix denied due to ACLs", "prefix", prefix, "accessorID", accessorID) @@ -396,6 +458,28 @@ func (s *Intention) Check( return errors.New("Check must be specified on args") } + // Get the ACL token for the request for the checks below. + var entMeta structs.EnterpriseMeta + authz, err := s.srv.ResolveTokenAndDefaultMeta(args.Token, &entMeta, nil) + if err != nil { + return err + } + + // Finish defaulting the namespace fields. + if query.SourceNS == "" { + query.SourceNS = entMeta.NamespaceOrDefault() + } + if query.DestinationNS == "" { + query.DestinationNS = entMeta.NamespaceOrDefault() + } + + if err := s.srv.validateEnterpriseIntentionNamespace(query.SourceNS, false); err != nil { + return fmt.Errorf("Invalid source namespace %q: %v", query.SourceNS, err) + } + if err := s.srv.validateEnterpriseIntentionNamespace(query.DestinationNS, false); err != nil { + return fmt.Errorf("Invalid destination namespace %q: %v", query.DestinationNS, err) + } + // Build the URI var uri connect.CertURI switch query.SourceType { @@ -409,12 +493,6 @@ func (s *Intention) Check( return fmt.Errorf("unsupported SourceType: %q", query.SourceType) } - // Get the ACL token for the request for the checks below. - rule, err := s.srv.ResolveToken(args.Token) - if err != nil { - return err - } - // Perform the ACL check. For Check we only require ServiceRead and // NOT IntentionRead because the Check API only returns pass/fail and // returns no other information about the intentions used. We could check @@ -424,7 +502,7 @@ func (s *Intention) Check( if prefix, ok := query.GetACLPrefix(); ok { var authzContext acl.AuthorizerContext query.FillAuthzContext(&authzContext) - if rule != nil && rule.ServiceRead(prefix, &authzContext) != acl.Allow { + if authz != nil && authz.ServiceRead(prefix, &authzContext) != acl.Allow { accessorID := s.aclAccessorID(args.Token) // todo(kit) Migrate intention access denial logging over to audit logging when we implement it s.logger.Warn("test on intention denied due to ACLs", "prefix", prefix, "accessorID", accessorID) @@ -470,14 +548,14 @@ func (s *Intention) Check( // NOTE(mitchellh): This is the same behavior as the agent authorize // endpoint. If this behavior is incorrect, we should also change it there // which is much more important. - rule, err = s.srv.ResolveToken("") + authz, err = s.srv.ResolveToken("") if err != nil { return err } reply.Allowed = true - if rule != nil { - reply.Allowed = rule.IntentionDefaultAllow(nil) == acl.Allow + if authz != nil { + reply.Allowed = authz.IntentionDefaultAllow(nil) == acl.Allow } return nil @@ -500,3 +578,13 @@ func (s *Intention) aclAccessorID(secretID string) string { } return ident.ID() } + +func (s *Intention) validateEnterpriseIntention(ixn *structs.Intention) error { + if err := s.srv.validateEnterpriseIntentionNamespace(ixn.SourceNS, true); err != nil { + return fmt.Errorf("Invalid source namespace %q: %v", ixn.SourceNS, err) + } + if err := s.srv.validateEnterpriseIntentionNamespace(ixn.DestinationNS, true); err != nil { + return fmt.Errorf("Invalid destination namespace %q: %v", ixn.DestinationNS, err) + } + return nil +} diff --git a/agent/consul/intention_endpoint_test.go b/agent/consul/intention_endpoint_test.go index 6359aa4824..5890ee5267 100644 --- a/agent/consul/intention_endpoint_test.go +++ b/agent/consul/intention_endpoint_test.go @@ -7,9 +7,7 @@ import ( "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/agent/structs" - "github.com/hashicorp/consul/testrpc" msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -17,14 +15,14 @@ import ( func TestIntentionApply_new(t *testing.T) { t.Parallel() - assert := assert.New(t) + require := require.New(t) dir1, s1 := testServer(t) defer os.RemoveAll(dir1) defer s1.Shutdown() codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Setup a basic record to create ixn := structs.IntentionRequest{ @@ -46,8 +44,8 @@ func TestIntentionApply_new(t *testing.T) { now := time.Now() // Create - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) - assert.NotEmpty(reply) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.NotEmpty(reply) // Read ixn.Intention.ID = reply @@ -57,19 +55,19 @@ func TestIntentionApply_new(t *testing.T) { IntentionID: ixn.Intention.ID, } var resp structs.IndexedIntentions - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp)) - assert.Len(resp.Intentions, 1) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp)) + require.Len(resp.Intentions, 1) actual := resp.Intentions[0] - assert.Equal(resp.Index, actual.ModifyIndex) - assert.WithinDuration(now, actual.CreatedAt, 5*time.Second) - assert.WithinDuration(now, actual.UpdatedAt, 5*time.Second) + require.Equal(resp.Index, actual.ModifyIndex) + require.WithinDuration(now, actual.CreatedAt, 5*time.Second) + require.WithinDuration(now, actual.UpdatedAt, 5*time.Second) actual.CreateIndex, actual.ModifyIndex = 0, 0 actual.CreatedAt = ixn.Intention.CreatedAt actual.UpdatedAt = ixn.Intention.UpdatedAt actual.Hash = ixn.Intention.Hash ixn.Intention.UpdatePrecedence() - assert.Equal(ixn.Intention, actual) + require.Equal(ixn.Intention, actual) } } @@ -77,14 +75,14 @@ func TestIntentionApply_new(t *testing.T) { func TestIntentionApply_defaultSourceType(t *testing.T) { t.Parallel() - assert := assert.New(t) + require := require.New(t) dir1, s1 := testServer(t) defer os.RemoveAll(dir1) defer s1.Shutdown() codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Setup a basic record to create ixn := structs.IntentionRequest{ @@ -101,8 +99,8 @@ func TestIntentionApply_defaultSourceType(t *testing.T) { var reply string // Create - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) - assert.NotEmpty(reply) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.NotEmpty(reply) // Read ixn.Intention.ID = reply @@ -112,10 +110,10 @@ func TestIntentionApply_defaultSourceType(t *testing.T) { IntentionID: ixn.Intention.ID, } var resp structs.IndexedIntentions - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp)) - assert.Len(resp.Intentions, 1) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp)) + require.Len(resp.Intentions, 1) actual := resp.Intentions[0] - assert.Equal(structs.IntentionSourceConsul, actual.SourceType) + require.Equal(structs.IntentionSourceConsul, actual.SourceType) } } @@ -123,14 +121,14 @@ func TestIntentionApply_defaultSourceType(t *testing.T) { func TestIntentionApply_createWithID(t *testing.T) { t.Parallel() - assert := assert.New(t) + require := require.New(t) dir1, s1 := testServer(t) defer os.RemoveAll(dir1) defer s1.Shutdown() codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Setup a basic record to create ixn := structs.IntentionRequest{ @@ -145,22 +143,22 @@ func TestIntentionApply_createWithID(t *testing.T) { // Create err := msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply) - assert.NotNil(err) - assert.Contains(err, "ID must be empty") + require.NotNil(err) + require.Contains(err, "ID must be empty") } // Test basic updating func TestIntentionApply_updateGood(t *testing.T) { t.Parallel() - assert := assert.New(t) + require := require.New(t) dir1, s1 := testServer(t) defer os.RemoveAll(dir1) defer s1.Shutdown() codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Setup a basic record to create ixn := structs.IntentionRequest{ @@ -179,8 +177,8 @@ func TestIntentionApply_updateGood(t *testing.T) { var reply string // Create - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) - assert.NotEmpty(reply) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.NotEmpty(reply) // Read CreatedAt var createdAt time.Time @@ -191,8 +189,8 @@ func TestIntentionApply_updateGood(t *testing.T) { IntentionID: ixn.Intention.ID, } var resp structs.IndexedIntentions - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp)) - assert.Len(resp.Intentions, 1) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp)) + require.Len(resp.Intentions, 1) actual := resp.Intentions[0] createdAt = actual.CreatedAt } @@ -204,7 +202,7 @@ func TestIntentionApply_updateGood(t *testing.T) { ixn.Op = structs.IntentionOpUpdate ixn.Intention.ID = reply ixn.Intention.SourceName = "*" - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) // Read ixn.Intention.ID = reply @@ -214,18 +212,18 @@ func TestIntentionApply_updateGood(t *testing.T) { IntentionID: ixn.Intention.ID, } var resp structs.IndexedIntentions - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp)) - assert.Len(resp.Intentions, 1) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp)) + require.Len(resp.Intentions, 1) actual := resp.Intentions[0] - assert.Equal(createdAt, actual.CreatedAt) - assert.WithinDuration(time.Now(), actual.UpdatedAt, 5*time.Second) + require.Equal(createdAt, actual.CreatedAt) + require.WithinDuration(time.Now(), actual.UpdatedAt, 5*time.Second) actual.CreateIndex, actual.ModifyIndex = 0, 0 actual.CreatedAt = ixn.Intention.CreatedAt actual.UpdatedAt = ixn.Intention.UpdatedAt actual.Hash = ixn.Intention.Hash ixn.Intention.UpdatePrecedence() - assert.Equal(ixn.Intention, actual) + require.Equal(ixn.Intention, actual) } } @@ -233,14 +231,14 @@ func TestIntentionApply_updateGood(t *testing.T) { func TestIntentionApply_updateNonExist(t *testing.T) { t.Parallel() - assert := assert.New(t) + require := require.New(t) dir1, s1 := testServer(t) defer os.RemoveAll(dir1) defer s1.Shutdown() codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Setup a basic record to create ixn := structs.IntentionRequest{ @@ -255,31 +253,29 @@ func TestIntentionApply_updateNonExist(t *testing.T) { // Create err := msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply) - assert.NotNil(err) - assert.Contains(err, "Cannot modify non-existent intention") + require.NotNil(err) + require.Contains(err, "Cannot modify non-existent intention") } // Test basic deleting func TestIntentionApply_deleteGood(t *testing.T) { t.Parallel() - assert := assert.New(t) + require := require.New(t) dir1, s1 := testServer(t) defer os.RemoveAll(dir1) defer s1.Shutdown() codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Setup a basic record to create ixn := structs.IntentionRequest{ Datacenter: "dc1", Op: structs.IntentionOpCreate, Intention: &structs.Intention{ - SourceNS: "test", SourceName: "test", - DestinationNS: "test", DestinationName: "test", Action: structs.IntentionActionAllow, }, @@ -287,13 +283,13 @@ func TestIntentionApply_deleteGood(t *testing.T) { var reply string // Create - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) - assert.NotEmpty(reply) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.NotEmpty(reply) // Delete ixn.Op = structs.IntentionOpDelete ixn.Intention.ID = reply - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) // Read ixn.Intention.ID = reply @@ -304,8 +300,8 @@ func TestIntentionApply_deleteGood(t *testing.T) { } var resp structs.IndexedIntentions err := msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp) - assert.NotNil(err) - assert.Contains(err, ErrIntentionNotFound.Error()) + require.NotNil(err) + require.Contains(err, ErrIntentionNotFound.Error()) } } @@ -313,7 +309,7 @@ func TestIntentionApply_deleteGood(t *testing.T) { func TestIntentionApply_aclDeny(t *testing.T) { t.Parallel() - assert := assert.New(t) + require := require.New(t) dir1, s1 := testServerWithConfig(t, func(c *Config) { c.ACLDatacenter = "dc1" c.ACLsEnabled = true @@ -325,7 +321,7 @@ func TestIntentionApply_aclDeny(t *testing.T) { codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Create an ACL with write permissions var token string @@ -346,7 +342,7 @@ service "foo" { }, WriteRequest: structs.WriteRequest{Token: "root"}, } - assert.Nil(msgpackrpc.CallWithCodec(codec, "ACL.Apply", &req, &token)) + require.Nil(msgpackrpc.CallWithCodec(codec, "ACL.Apply", &req, &token)) } // Setup a basic record to create @@ -360,11 +356,11 @@ service "foo" { // Create without a token should error since default deny var reply string err := msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply) - assert.True(acl.IsErrPermissionDenied(err)) + require.True(acl.IsErrPermissionDenied(err)) // Now add the token and try again. ixn.WriteRequest.Token = token - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) // Read ixn.Intention.ID = reply @@ -375,17 +371,17 @@ service "foo" { QueryOptions: structs.QueryOptions{Token: "root"}, } var resp structs.IndexedIntentions - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp)) - assert.Len(resp.Intentions, 1) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp)) + require.Len(resp.Intentions, 1) actual := resp.Intentions[0] - assert.Equal(resp.Index, actual.ModifyIndex) + require.Equal(resp.Index, actual.ModifyIndex) actual.CreateIndex, actual.ModifyIndex = 0, 0 actual.CreatedAt = ixn.Intention.CreatedAt actual.UpdatedAt = ixn.Intention.UpdatedAt actual.Hash = ixn.Intention.Hash ixn.Intention.UpdatePrecedence() - assert.Equal(ixn.Intention, actual) + require.Equal(ixn.Intention, actual) } } @@ -707,7 +703,7 @@ func TestIntention_WildcardACLEnforcement(t *testing.T) { func TestIntentionApply_aclDelete(t *testing.T) { t.Parallel() - assert := assert.New(t) + require := require.New(t) dir1, s1 := testServerWithConfig(t, func(c *Config) { c.ACLDatacenter = "dc1" c.ACLsEnabled = true @@ -719,7 +715,7 @@ func TestIntentionApply_aclDelete(t *testing.T) { codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Create an ACL with write permissions var token string @@ -740,7 +736,7 @@ service "foo" { }, WriteRequest: structs.WriteRequest{Token: "root"}, } - assert.Nil(msgpackrpc.CallWithCodec(codec, "ACL.Apply", &req, &token)) + require.Nil(msgpackrpc.CallWithCodec(codec, "ACL.Apply", &req, &token)) } // Setup a basic record to create @@ -754,18 +750,18 @@ service "foo" { // Create var reply string - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) // Try to do a delete with no token; this should get rejected. ixn.Op = structs.IntentionOpDelete ixn.Intention.ID = reply ixn.WriteRequest.Token = "" err := msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply) - assert.True(acl.IsErrPermissionDenied(err)) + require.True(acl.IsErrPermissionDenied(err)) // Try again with the original token. This should go through. ixn.WriteRequest.Token = token - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) // Verify it is gone { @@ -775,8 +771,8 @@ service "foo" { } var resp structs.IndexedIntentions err := msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp) - assert.NotNil(err) - assert.Contains(err.Error(), ErrIntentionNotFound.Error()) + require.NotNil(err) + require.Contains(err.Error(), ErrIntentionNotFound.Error()) } } @@ -784,7 +780,7 @@ service "foo" { func TestIntentionApply_aclUpdate(t *testing.T) { t.Parallel() - assert := assert.New(t) + require := require.New(t) dir1, s1 := testServerWithConfig(t, func(c *Config) { c.ACLDatacenter = "dc1" c.ACLsEnabled = true @@ -796,7 +792,7 @@ func TestIntentionApply_aclUpdate(t *testing.T) { codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Create an ACL with write permissions var token string @@ -817,7 +813,7 @@ service "foo" { }, WriteRequest: structs.WriteRequest{Token: "root"}, } - assert.Nil(msgpackrpc.CallWithCodec(codec, "ACL.Apply", &req, &token)) + require.Nil(msgpackrpc.CallWithCodec(codec, "ACL.Apply", &req, &token)) } // Setup a basic record to create @@ -831,25 +827,25 @@ service "foo" { // Create var reply string - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) // Try to do an update without a token; this should get rejected. ixn.Op = structs.IntentionOpUpdate ixn.Intention.ID = reply ixn.WriteRequest.Token = "" err := msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply) - assert.True(acl.IsErrPermissionDenied(err)) + require.True(acl.IsErrPermissionDenied(err)) // Try again with the original token; this should go through. ixn.WriteRequest.Token = token - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) } // Test apply with a management token func TestIntentionApply_aclManagement(t *testing.T) { t.Parallel() - assert := assert.New(t) + require := require.New(t) dir1, s1 := testServerWithConfig(t, func(c *Config) { c.ACLDatacenter = "dc1" c.ACLsEnabled = true @@ -861,7 +857,7 @@ func TestIntentionApply_aclManagement(t *testing.T) { codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Setup a basic record to create ixn := structs.IntentionRequest{ @@ -874,23 +870,23 @@ func TestIntentionApply_aclManagement(t *testing.T) { // Create var reply string - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) ixn.Intention.ID = reply // Update ixn.Op = structs.IntentionOpUpdate - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) // Delete ixn.Op = structs.IntentionOpDelete - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) } // Test update changing the name where an ACL won't allow it func TestIntentionApply_aclUpdateChange(t *testing.T) { t.Parallel() - assert := assert.New(t) + require := require.New(t) dir1, s1 := testServerWithConfig(t, func(c *Config) { c.ACLDatacenter = "dc1" c.ACLsEnabled = true @@ -902,7 +898,7 @@ func TestIntentionApply_aclUpdateChange(t *testing.T) { codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Create an ACL with write permissions var token string @@ -923,7 +919,7 @@ service "foo" { }, WriteRequest: structs.WriteRequest{Token: "root"}, } - assert.Nil(msgpackrpc.CallWithCodec(codec, "ACL.Apply", &req, &token)) + require.Nil(msgpackrpc.CallWithCodec(codec, "ACL.Apply", &req, &token)) } // Setup a basic record to create @@ -937,7 +933,7 @@ service "foo" { // Create var reply string - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) // Try to do an update without a token; this should get rejected. ixn.Op = structs.IntentionOpUpdate @@ -945,14 +941,13 @@ service "foo" { ixn.Intention.DestinationName = "foo" ixn.WriteRequest.Token = token err := msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply) - assert.True(acl.IsErrPermissionDenied(err)) + require.True(acl.IsErrPermissionDenied(err)) } // Test reading with ACLs func TestIntentionGet_acl(t *testing.T) { t.Parallel() - assert := assert.New(t) dir1, s1 := testServerWithConfig(t, func(c *Config) { c.ACLDatacenter = "dc1" c.ACLsEnabled = true @@ -964,29 +959,15 @@ func TestIntentionGet_acl(t *testing.T) { codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Create an ACL with service write permissions. This will grant - // intentions read. - var token string - { - var rules = ` -service "foo" { - policy = "write" -}` - - req := structs.ACLRequest{ - Datacenter: "dc1", - Op: structs.ACLSet, - ACL: structs.ACL{ - Name: "User token", - Type: structs.ACLTokenTypeClient, - Rules: rules, - }, - WriteRequest: structs.WriteRequest{Token: "root"}, - } - assert.Nil(msgpackrpc.CallWithCodec(codec, "ACL.Apply", &req, &token)) - } + // intentions read on either end of an intention. + token, err := upsertTestTokenWithPolicyRules(codec, "root", "dc1", ` + service "foobar" { + policy = "write" + }`) + require.NoError(t, err) // Setup a basic record to create ixn := structs.IntentionRequest{ @@ -999,11 +980,10 @@ service "foo" { // Create var reply string - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.NoError(t, msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) ixn.Intention.ID = reply - // Read without token should be error - { + t.Run("Read by ID without token should be error", func(t *testing.T) { req := &structs.IntentionQueryRequest{ Datacenter: "dc1", IntentionID: ixn.Intention.ID, @@ -1011,35 +991,68 @@ service "foo" { var resp structs.IndexedIntentions err := msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp) - assert.True(acl.IsErrPermissionDenied(err)) - assert.Len(resp.Intentions, 0) - } + require.True(t, acl.IsErrPermissionDenied(err)) + require.Len(t, resp.Intentions, 0) + }) - // Read with token should work - { + t.Run("Read by ID with token should work", func(t *testing.T) { req := &structs.IntentionQueryRequest{ Datacenter: "dc1", IntentionID: ixn.Intention.ID, - QueryOptions: structs.QueryOptions{Token: token}, + QueryOptions: structs.QueryOptions{Token: token.SecretID}, } var resp structs.IndexedIntentions - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp)) - assert.Len(resp.Intentions, 1) - } + require.NoError(t, msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp)) + require.Len(t, resp.Intentions, 1) + }) + + t.Run("Read by Exact without token should be error", func(t *testing.T) { + req := &structs.IntentionQueryRequest{ + Datacenter: "dc1", + Exact: &structs.IntentionQueryExact{ + SourceNS: structs.IntentionDefaultNamespace, + SourceName: "api", + DestinationNS: structs.IntentionDefaultNamespace, + DestinationName: "foobar", + }, + } + + var resp structs.IndexedIntentions + err := msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp) + require.True(t, acl.IsErrPermissionDenied(err)) + require.Len(t, resp.Intentions, 0) + }) + + t.Run("Read by Exact with token should work", func(t *testing.T) { + req := &structs.IntentionQueryRequest{ + Datacenter: "dc1", + Exact: &structs.IntentionQueryExact{ + SourceNS: structs.IntentionDefaultNamespace, + SourceName: "api", + DestinationNS: structs.IntentionDefaultNamespace, + DestinationName: "foobar", + }, + QueryOptions: structs.QueryOptions{Token: token.SecretID}, + } + + var resp structs.IndexedIntentions + require.NoError(t, msgpackrpc.CallWithCodec(codec, "Intention.Get", req, &resp)) + require.Len(t, resp.Intentions, 1) + }) } func TestIntentionList(t *testing.T) { t.Parallel() - assert := assert.New(t) + require := require.New(t) dir1, s1 := testServer(t) defer os.RemoveAll(dir1) defer s1.Shutdown() codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Test with no intentions inserted yet { @@ -1047,9 +1060,9 @@ func TestIntentionList(t *testing.T) { Datacenter: "dc1", } var resp structs.IndexedIntentions - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.List", req, &resp)) - assert.NotNil(resp.Intentions) - assert.Len(resp.Intentions, 0) + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.List", req, &resp)) + require.NotNil(resp.Intentions) + require.Len(resp.Intentions, 0) } } @@ -1063,7 +1076,7 @@ func TestIntentionList_acl(t *testing.T) { codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) waitForNewACLs(t, s1) token, err := upsertTestTokenWithPolicyRules(codec, TestDefaultMasterToken, "dc1", `service_prefix "foo" { policy = "write" }`) @@ -1138,25 +1151,20 @@ func TestIntentionList_acl(t *testing.T) { func TestIntentionMatch_good(t *testing.T) { t.Parallel() - assert := assert.New(t) dir1, s1 := testServer(t) defer os.RemoveAll(dir1) defer s1.Shutdown() codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Create some records { insert := [][]string{ - {"foo", "*", "foo", "*"}, - {"foo", "*", "foo", "bar"}, - {"foo", "*", "foo", "baz"}, // shouldn't match - {"foo", "*", "bar", "bar"}, // shouldn't match - {"foo", "*", "bar", "*"}, // shouldn't match - {"foo", "*", "*", "*"}, - {"bar", "*", "foo", "bar"}, // duplicate destination different source + {"default", "*", "default", "*"}, + {"default", "*", "default", "bar"}, + {"default", "*", "default", "baz"}, // shouldn't match } for _, v := range insert { @@ -1174,7 +1182,7 @@ func TestIntentionMatch_good(t *testing.T) { // Create var reply string - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + require.Nil(t, msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) } } @@ -1184,22 +1192,17 @@ func TestIntentionMatch_good(t *testing.T) { Match: &structs.IntentionQueryMatch{ Type: structs.IntentionMatchDestination, Entries: []structs.IntentionMatchEntry{ - { - Namespace: "foo", - Name: "bar", - }, + {Name: "bar"}, }, }, } var resp structs.IndexedIntentionMatches - assert.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Match", req, &resp)) - assert.Len(resp.Matches, 1) + require.Nil(t, msgpackrpc.CallWithCodec(codec, "Intention.Match", req, &resp)) + require.Len(t, resp.Matches, 1) expected := [][]string{ - {"bar", "*", "foo", "bar"}, - {"foo", "*", "foo", "bar"}, - {"foo", "*", "foo", "*"}, - {"foo", "*", "*", "*"}, + {"default", "*", "default", "bar"}, + {"default", "*", "default", "*"}, } var actual [][]string for _, ixn := range resp.Matches[0] { @@ -1210,7 +1213,7 @@ func TestIntentionMatch_good(t *testing.T) { ixn.DestinationName, }) } - assert.Equal(expected, actual) + require.Equal(t, expected, actual) } // Test matching with ACLs @@ -1299,36 +1302,32 @@ func TestIntentionMatch_acl(t *testing.T) { func TestIntentionCheck_defaultNoACL(t *testing.T) { t.Parallel() - require := require.New(t) dir1, s1 := testServer(t) defer os.RemoveAll(dir1) defer s1.Shutdown() codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Test req := &structs.IntentionQueryRequest{ Datacenter: "dc1", Check: &structs.IntentionQueryCheck{ - SourceNS: "foo", SourceName: "bar", - DestinationNS: "foo", DestinationName: "qux", SourceType: structs.IntentionSourceConsul, }, } var resp structs.IntentionQueryCheckResponse - require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Check", req, &resp)) - require.True(resp.Allowed) + require.NoError(t, msgpackrpc.CallWithCodec(codec, "Intention.Check", req, &resp)) + require.True(t, resp.Allowed) } // Test the Check method defaults to deny with allowlist ACLs. func TestIntentionCheck_defaultACLDeny(t *testing.T) { t.Parallel() - require := require.New(t) dir1, s1 := testServerWithConfig(t, func(c *Config) { c.ACLDatacenter = "dc1" c.ACLsEnabled = true @@ -1340,30 +1339,27 @@ func TestIntentionCheck_defaultACLDeny(t *testing.T) { codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Check req := &structs.IntentionQueryRequest{ Datacenter: "dc1", Check: &structs.IntentionQueryCheck{ - SourceNS: "foo", SourceName: "bar", - DestinationNS: "foo", DestinationName: "qux", SourceType: structs.IntentionSourceConsul, }, } req.Token = "root" var resp structs.IntentionQueryCheckResponse - require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Check", req, &resp)) - require.False(resp.Allowed) + require.NoError(t, msgpackrpc.CallWithCodec(codec, "Intention.Check", req, &resp)) + require.False(t, resp.Allowed) } // Test the Check method defaults to deny with denylist ACLs. func TestIntentionCheck_defaultACLAllow(t *testing.T) { t.Parallel() - require := require.New(t) dir1, s1 := testServerWithConfig(t, func(c *Config) { c.ACLDatacenter = "dc1" c.ACLsEnabled = true @@ -1375,30 +1371,27 @@ func TestIntentionCheck_defaultACLAllow(t *testing.T) { codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Check req := &structs.IntentionQueryRequest{ Datacenter: "dc1", Check: &structs.IntentionQueryCheck{ - SourceNS: "foo", SourceName: "bar", - DestinationNS: "foo", DestinationName: "qux", SourceType: structs.IntentionSourceConsul, }, } req.Token = "root" var resp structs.IntentionQueryCheckResponse - require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Check", req, &resp)) - require.True(resp.Allowed) + require.NoError(t, msgpackrpc.CallWithCodec(codec, "Intention.Check", req, &resp)) + require.True(t, resp.Allowed) } // Test the Check method requires service:read permission. func TestIntentionCheck_aclDeny(t *testing.T) { t.Parallel() - require := require.New(t) dir1, s1 := testServerWithConfig(t, func(c *Config) { c.ACLDatacenter = "dc1" c.ACLsEnabled = true @@ -1410,7 +1403,7 @@ func TestIntentionCheck_aclDeny(t *testing.T) { codec := rpcClient(t, s1) defer codec.Close() - testrpc.WaitForLeader(t, s1.RPC, "dc1") + waitForLeaderEstablishment(t, s1) // Create an ACL with service read permissions. This will grant permission. var token string @@ -1430,16 +1423,14 @@ service "bar" { }, WriteRequest: structs.WriteRequest{Token: "root"}, } - require.Nil(msgpackrpc.CallWithCodec(codec, "ACL.Apply", &req, &token)) + require.NoError(t, msgpackrpc.CallWithCodec(codec, "ACL.Apply", &req, &token)) } // Check req := &structs.IntentionQueryRequest{ Datacenter: "dc1", Check: &structs.IntentionQueryCheck{ - SourceNS: "foo", SourceName: "qux", - DestinationNS: "foo", DestinationName: "baz", SourceType: structs.IntentionSourceConsul, }, @@ -1447,7 +1438,7 @@ service "bar" { req.Token = token var resp structs.IntentionQueryCheckResponse err := msgpackrpc.CallWithCodec(codec, "Intention.Check", req, &resp) - require.True(acl.IsErrPermissionDenied(err)) + require.True(t, acl.IsErrPermissionDenied(err)) } // Test the Check method returns allow/deny properly. diff --git a/agent/consul/leader_connect.go b/agent/consul/leader_connect.go index e79ae917a2..fcad53d65b 100644 --- a/agent/consul/leader_connect.go +++ b/agent/consul/leader_connect.go @@ -559,6 +559,7 @@ func (s *Server) startConnectLeader() { s.leaderRoutineManager.Start(secondaryCARootWatchRoutineName, s.secondaryCARootWatch) s.leaderRoutineManager.Start(intentionReplicationRoutineName, s.replicateIntentions) s.leaderRoutineManager.Start(secondaryCertRenewWatchRoutineName, s.secondaryIntermediateCertRenewalWatch) + s.startConnectLeaderEnterprise() } s.leaderRoutineManager.Start(caRootPruningRoutineName, s.runCARootPruning) @@ -569,6 +570,7 @@ func (s *Server) stopConnectLeader() { s.leaderRoutineManager.Stop(secondaryCARootWatchRoutineName) s.leaderRoutineManager.Stop(intentionReplicationRoutineName) s.leaderRoutineManager.Stop(caRootPruningRoutineName) + s.stopConnectLeaderEnterprise() } func (s *Server) runCARootPruning(ctx context.Context) error { @@ -789,7 +791,7 @@ func (s *Server) replicateIntentions(ctx context.Context) error { return err } - _, local, err := s.fsm.State().Intentions(nil) + _, local, err := s.fsm.State().Intentions(nil, s.replicationEnterpriseMeta()) if err != nil { return err } diff --git a/agent/consul/leader_connect_oss.go b/agent/consul/leader_connect_oss.go new file mode 100644 index 0000000000..71e5e9c565 --- /dev/null +++ b/agent/consul/leader_connect_oss.go @@ -0,0 +1,141 @@ +// +build !consulent + +package consul + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/hashicorp/consul/agent/structs" +) + +const intentionUpgradeCleanupRoutineName = "intention cleanup" + +func (s *Server) startConnectLeaderEnterprise() { + if s.config.PrimaryDatacenter != s.config.Datacenter { + // intention cleanup should only run in the primary + return + } + s.leaderRoutineManager.Start(intentionUpgradeCleanupRoutineName, s.runIntentionUpgradeCleanup) +} + +func (s *Server) stopConnectLeaderEnterprise() { + // will be a no-op when not started + s.leaderRoutineManager.Stop(intentionUpgradeCleanupRoutineName) +} + +func (s *Server) runIntentionUpgradeCleanup(ctx context.Context) error { + // TODO(rb): handle retry? + + // Remove any intention in OSS that happened to have used a non-default + // namespace. + // + // The one exception is that if we find wildcards namespaces we "upgrade" + // them to "default" if there isn't already an existing intention. + _, ixns, err := s.fsm.State().Intentions(nil, structs.WildcardEnterpriseMeta()) + if err != nil { + return fmt.Errorf("failed to list intentions: %v", err) + } + + // default/ => default/ || OK + // default/* => default/ || OK + // */* => default/ || becomes: default/* => default/ + // default/ => default/* || OK + // default/* => default/* || OK + // */* => default/* || becomes: default/* => default/* + // default/ => */* || becomes: default/ => default/* + // default/* => */* || becomes: default/* => default/* + // */* => */* || becomes: default/* => default/* + + type intentionName struct { + SourceNS, SourceName string + DestinationNS, DestinationName string + } + + var ( + retained = make(map[intentionName]struct{}) + tryUpgrades = make(map[intentionName]*structs.Intention) + removeIDs []string + ) + for _, ixn := range ixns { + srcNS := strings.ToLower(ixn.SourceNS) + if srcNS == "" { + srcNS = structs.IntentionDefaultNamespace + } + dstNS := strings.ToLower(ixn.DestinationNS) + if dstNS == "" { + dstNS = structs.IntentionDefaultNamespace + } + + if srcNS == structs.IntentionDefaultNamespace && dstNS == structs.IntentionDefaultNamespace { + name := intentionName{ + srcNS, ixn.SourceName, + dstNS, ixn.DestinationName, + } + retained[name] = struct{}{} + continue // a-ok for OSS + } + + // If anything is wildcarded, attempt to reify it as "default". + if srcNS == structs.WildcardSpecifier || dstNS == structs.WildcardSpecifier { + updated := ixn.Clone() + if srcNS == structs.WildcardSpecifier { + updated.SourceNS = structs.IntentionDefaultNamespace + } + if dstNS == structs.WildcardSpecifier { + updated.DestinationNS = structs.IntentionDefaultNamespace + } + + // Run parts of the checks in Intention.prepareApplyUpdate. + + // We always update the updatedat field. + updated.UpdatedAt = time.Now().UTC() + + // Set the precedence + updated.UpdatePrecedence() + + // make sure we set the hash prior to raft application + updated.SetHash() + + name := intentionName{ + updated.SourceNS, updated.SourceName, + updated.DestinationNS, updated.DestinationName, + } + tryUpgrades[name] = updated + } else { + removeIDs = append(removeIDs, ixn.ID) + } + } + + for name, updated := range tryUpgrades { + if _, collision := retained[name]; collision { + // The update we wanted to do would collide with an existing intention + // so delete our original wildcard intention instead. + removeIDs = append(removeIDs, updated.ID) + } else { + req := structs.IntentionRequest{ + Op: structs.IntentionOpUpdate, + Intention: updated, + } + if _, err := s.raftApply(structs.IntentionRequestType, &req); err != nil { + return fmt.Errorf("failed to remove wildcard namespaces from intention %q: %v", updated.ID, err) + } + } + } + + for _, id := range removeIDs { + req := structs.IntentionRequest{ + Op: structs.IntentionOpDelete, + Intention: &structs.Intention{ + ID: id, + }, + } + if _, err := s.raftApply(structs.IntentionRequestType, &req); err != nil { + return fmt.Errorf("failed to remove intention with invalid namespace %q: %v", id, err) + } + } + + return nil // transition complete +} diff --git a/agent/consul/leader_connect_oss_test.go b/agent/consul/leader_connect_oss_test.go new file mode 100644 index 0000000000..f46fcd9b67 --- /dev/null +++ b/agent/consul/leader_connect_oss_test.go @@ -0,0 +1,180 @@ +// +build !consulent + +package consul + +import ( + "context" + "os" + "testing" + "time" + + "github.com/hashicorp/consul/agent/structs" + tokenStore "github.com/hashicorp/consul/agent/token" + "github.com/stretchr/testify/require" +) + +func TestLeader_OSS_IntentionUpgradeCleanup(t *testing.T) { + t.Parallel() + + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.Datacenter = "dc1" + c.ACLDatacenter = "dc1" + c.ACLsEnabled = true + c.ACLMasterToken = "root" + c.ACLDefaultPolicy = "deny" + // set the build to ensure all the version checks pass and enable all the connect features that operate cross-dc + c.Build = "1.6.0" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + codec := rpcClient(t, s1) + defer codec.Close() + + waitForLeaderEstablishment(t, s1) + + s1.tokens.UpdateAgentToken("root", tokenStore.TokenSourceConfig) + + lastIndex := uint64(0) + nextIndex := func() uint64 { + lastIndex++ + return lastIndex + } + + wildEntMeta := structs.WildcardEnterpriseMeta() + + resetIntentions := func(t *testing.T) { + t.Helper() + _, ixns, err := s1.fsm.State().Intentions(nil, wildEntMeta) + require.NoError(t, err) + + for _, ixn := range ixns { + require.NoError(t, s1.fsm.State().IntentionDelete(nextIndex(), ixn.ID)) + } + } + + compare := func(t *testing.T, expect [][]string) { + t.Helper() + + _, ixns, err := s1.fsm.State().Intentions(nil, wildEntMeta) + require.NoError(t, err) + + var actual [][]string + for _, ixn := range ixns { + actual = append(actual, []string{ + ixn.SourceNS, + ixn.SourceName, + ixn.DestinationNS, + ixn.DestinationName, + }) + } + require.ElementsMatch(t, expect, actual) + } + + type testCase struct { + insert [][]string + expect [][]string + } + + cases := map[string]testCase{ + "no change": { + insert: [][]string{ + {"default", "foo", "default", "bar"}, + {"default", "*", "default", "*"}, + }, + expect: [][]string{ + {"default", "foo", "default", "bar"}, + {"default", "*", "default", "*"}, + }, + }, + "non-wildcard deletions": { + insert: [][]string{ + {"default", "foo", "default", "bar"}, + {"alpha", "*", "default", "bar"}, + {"default", "foo", "beta", "*"}, + {"alpha", "zoo", "beta", "bar"}, + }, + expect: [][]string{ + {"default", "foo", "default", "bar"}, + }, + }, + "updates with no deletions and no collisions": { + insert: [][]string{ + {"default", "foo", "default", "bar"}, + {"default", "foo", "*", "*"}, + {"*", "*", "default", "bar"}, + {"*", "*", "*", "*"}, + }, + expect: [][]string{ + {"default", "foo", "default", "bar"}, + {"default", "foo", "default", "*"}, + {"default", "*", "default", "bar"}, + {"default", "*", "default", "*"}, + }, + }, + "updates with only collision deletions": { + insert: [][]string{ + {"default", "foo", "default", "bar"}, + {"default", "foo", "default", "*"}, + {"default", "foo", "*", "*"}, + {"default", "*", "default", "bar"}, + {"*", "*", "default", "bar"}, + {"default", "*", "default", "*"}, + {"*", "*", "*", "*"}, + }, + expect: [][]string{ + {"default", "foo", "default", "bar"}, + {"default", "foo", "default", "*"}, + {"default", "*", "default", "bar"}, + {"default", "*", "default", "*"}, + }, + }, + "a bit of everything": { + insert: [][]string{ + {"default", "foo", "default", "bar"}, // retained + {"default", "foo", "*", "*"}, // upgrade + {"default", "*", "default", "bar"}, // retained in collision + {"*", "*", "default", "bar"}, // deleted in collision + {"default", "*", "default", "*"}, // retained in collision + {"*", "*", "*", "*"}, // deleted in collision + {"alpha", "*", "default", "bar"}, // deleted + {"default", "foo", "beta", "*"}, // deleted + {"alpha", "zoo", "beta", "bar"}, // deleted + }, + expect: [][]string{ + {"default", "foo", "default", "bar"}, + {"default", "foo", "default", "*"}, + {"default", "*", "default", "bar"}, + {"default", "*", "default", "*"}, + }, + }, + } + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + resetIntentions(t) + + // Do something super evil and directly reach into the FSM to seed it with "bad" data. + for _, elem := range tc.insert { + require.Len(t, elem, 4) + ixn := structs.TestIntention(t) + ixn.ID = generateUUID() + ixn.SourceNS = elem[0] + ixn.SourceName = elem[1] + ixn.DestinationNS = elem[2] + ixn.DestinationName = elem[3] + ixn.CreatedAt = time.Now().UTC() + ixn.UpdatedAt = ixn.CreatedAt + require.NoError(t, s1.fsm.State().IntentionSet(nextIndex(), ixn)) + } + + // Sleep a bit so that the UpdatedAt field will definitely be different + time.Sleep(1 * time.Millisecond) + + // TODO: figure out how to test this properly during leader startup + + require.NoError(t, s1.runIntentionUpgradeCleanup(context.Background())) + + compare(t, tc.expect) + }) + } +} diff --git a/agent/consul/state/intention.go b/agent/consul/state/intention.go index 4efed00d5c..0676b51736 100644 --- a/agent/consul/state/intention.go +++ b/agent/consul/state/intention.go @@ -126,7 +126,7 @@ func (s *Restore) Intention(ixn *structs.Intention) error { } // Intentions returns the list of all intentions. -func (s *Store) Intentions(ws memdb.WatchSet) (uint64, structs.Intentions, error) { +func (s *Store) Intentions(ws memdb.WatchSet, entMeta *structs.EnterpriseMeta) (uint64, structs.Intentions, error) { tx := s.db.Txn(false) defer tx.Abort() @@ -136,11 +136,11 @@ func (s *Store) Intentions(ws memdb.WatchSet) (uint64, structs.Intentions, error idx = 1 } - // Get all intentions - iter, err := tx.Get(intentionsTableName, "id") + iter, err := s.intentionListTxn(tx, entMeta) if err != nil { return 0, nil, fmt.Errorf("failed intention lookup: %s", err) } + ws.Add(iter.WatchCh()) var results structs.Intentions @@ -250,6 +250,38 @@ func (s *Store) IntentionGet(ws memdb.WatchSet, id string) (uint64, *structs.Int return idx, result, nil } +// IntentionGetExact returns the given intention by it's full unique name. +func (s *Store) IntentionGetExact(ws memdb.WatchSet, args *structs.IntentionQueryExact) (uint64, *structs.Intention, error) { + tx := s.db.Txn(false) + defer tx.Abort() + + if err := args.Validate(); err != nil { + return 0, nil, err + } + + // Get the table index. + idx := maxIndexTxn(tx, intentionsTableName) + if idx < 1 { + idx = 1 + } + + // Look up by its full name. + watchCh, intention, err := tx.FirstWatch(intentionsTableName, "source_destination", + args.SourceNS, args.SourceName, args.DestinationNS, args.DestinationName) + if err != nil { + return 0, nil, fmt.Errorf("failed intention lookup: %s", err) + } + ws.Add(watchCh) + + // Convert the interface{} if it is non-nil + var result *structs.Intention + if intention != nil { + result = intention.(*structs.Intention) + } + + return idx, result, nil +} + // IntentionDelete deletes the given intention by ID. func (s *Store) IntentionDelete(idx uint64, id string) error { tx := s.db.WriteTxn(idx) diff --git a/agent/consul/state/intention_oss.go b/agent/consul/state/intention_oss.go new file mode 100644 index 0000000000..8e99653753 --- /dev/null +++ b/agent/consul/state/intention_oss.go @@ -0,0 +1,13 @@ +// +build !consulent + +package state + +import ( + "github.com/hashicorp/consul/agent/structs" + memdb "github.com/hashicorp/go-memdb" +) + +func (s *Store) intentionListTxn(tx *txn, _ *structs.EnterpriseMeta) (memdb.ResultIterator, error) { + // Get all intentions + return tx.Get(intentionsTableName, "id") +} diff --git a/agent/consul/state/intention_test.go b/agent/consul/state/intention_test.go index e8f72cc65a..ec9999d335 100644 --- a/agent/consul/state/intention_test.go +++ b/agent/consul/state/intention_test.go @@ -223,64 +223,65 @@ func TestStore_IntentionDelete(t *testing.T) { } func TestStore_IntentionsList(t *testing.T) { - assert := assert.New(t) s := testStateStore(t) + entMeta := structs.WildcardEnterpriseMeta() + // Querying with no results returns nil. ws := memdb.NewWatchSet() - idx, res, err := s.Intentions(ws) - assert.NoError(err) - assert.Nil(res) - assert.Equal(uint64(1), idx) + idx, res, err := s.Intentions(ws, entMeta) + require.NoError(t, err) + require.Nil(t, res) + require.Equal(t, uint64(1), idx) + + testIntention := func(srcNS, src, dstNS, dst string) *structs.Intention { + id := testUUID() + return &structs.Intention{ + ID: id, + SourceNS: srcNS, + SourceName: src, + DestinationNS: dstNS, + DestinationName: dst, + Meta: map[string]string{}, + } + } + + cmpIntention := func(ixn *structs.Intention, id string, index uint64) *structs.Intention { + ixn.ID = id + ixn.CreateIndex = index + ixn.ModifyIndex = index + ixn.UpdatePrecedence() // to match what is returned... + return ixn + } // Create some intentions ixns := structs.Intentions{ - &structs.Intention{ - ID: testUUID(), - Meta: map[string]string{}, - }, - &structs.Intention{ - ID: testUUID(), - Meta: map[string]string{}, - }, + testIntention("default", "foo", "default", "bar"), + testIntention("default", "foo", "default", "*"), + testIntention("*", "*", "default", "*"), + testIntention("default", "*", "*", "*"), + testIntention("*", "*", "*", "*"), } - // Force deterministic sort order - ixns[0].ID = "a" + ixns[0].ID[1:] - ixns[1].ID = "b" + ixns[1].ID[1:] - // Create for i, ixn := range ixns { - assert.NoError(s.IntentionSet(uint64(1+i), ixn)) + require.NoError(t, s.IntentionSet(uint64(1+i), ixn)) } - assert.True(watchFired(ws), "watch fired") + require.True(t, watchFired(ws), "watch fired") // Read it back and verify. expected := structs.Intentions{ - &structs.Intention{ - ID: ixns[0].ID, - Meta: map[string]string{}, - RaftIndex: structs.RaftIndex{ - CreateIndex: 1, - ModifyIndex: 1, - }, - }, - &structs.Intention{ - ID: ixns[1].ID, - Meta: map[string]string{}, - RaftIndex: structs.RaftIndex{ - CreateIndex: 2, - ModifyIndex: 2, - }, - }, + cmpIntention(testIntention("default", "foo", "default", "bar"), ixns[0].ID, 1), + cmpIntention(testIntention("default", "foo", "default", "*"), ixns[1].ID, 2), + cmpIntention(testIntention("*", "*", "default", "*"), ixns[2].ID, 3), + cmpIntention(testIntention("default", "*", "*", "*"), ixns[3].ID, 4), + cmpIntention(testIntention("*", "*", "*", "*"), ixns[4].ID, 5), } - for i := range expected { - expected[i].UpdatePrecedence() // to match what is returned... - } - idx, actual, err := s.Intentions(nil) - assert.NoError(err) - assert.Equal(idx, uint64(2)) - assert.Equal(expected, actual) + + idx, actual, err := s.Intentions(nil, entMeta) + require.NoError(t, err) + require.Equal(t, idx, uint64(5)) + require.ElementsMatch(t, expected, actual) } // Test the matrix of match logic. @@ -551,7 +552,8 @@ func TestStore_Intention_Snapshot_Restore(t *testing.T) { // Intentions are returned precedence sorted unlike the snapshot so we need // to rearrange the expected slice some. expected[0], expected[1], expected[2] = expected[1], expected[2], expected[0] - idx, actual, err := s.Intentions(nil) + entMeta := structs.WildcardEnterpriseMeta() + idx, actual, err := s.Intentions(nil, entMeta) assert.NoError(err) assert.Equal(idx, uint64(6)) assert.Equal(expected, actual) diff --git a/agent/consul/state/txn_test.go b/agent/consul/state/txn_test.go index 66f19b61ae..7e2f2ff740 100644 --- a/agent/consul/state/txn_test.go +++ b/agent/consul/state/txn_test.go @@ -77,7 +77,7 @@ func TestStateStore_Txn_Intention(t *testing.T) { require.Equal(t, expected, results) // Pull the resulting state store contents. - idx, actual, err := s.Intentions(nil) + idx, actual, err := s.Intentions(nil, nil) require.NoError(t, err) require.Equal(t, uint64(3), idx, "wrong index") diff --git a/agent/http_oss.go b/agent/http_oss.go index e361483192..bcf19de77a 100644 --- a/agent/http_oss.go +++ b/agent/http_oss.go @@ -20,6 +20,18 @@ func (s *HTTPServer) parseEntMeta(req *http.Request, entMeta *structs.Enterprise return nil } +func (s *HTTPServer) validateEnterpriseIntentionNamespace(logName, ns string, _ bool) error { + if ns == "" { + return nil + } else if strings.ToLower(ns) == structs.IntentionDefaultNamespace { + return nil + } + + // No special handling for wildcard namespaces as they are pointless in OSS. + + return BadRequestError{Reason: "Invalid " + logName + "(" + ns + ")" + ": Namespaces is a Consul Enterprise feature"} +} + func (s *HTTPServer) parseEntMetaNoWildcard(req *http.Request, _ *structs.EnterpriseMeta) error { return s.parseEntMeta(req, nil) } diff --git a/agent/http_register.go b/agent/http_register.go index 96ee58d05a..3d9d55c737 100644 --- a/agent/http_register.go +++ b/agent/http_register.go @@ -76,6 +76,7 @@ func init() { registerEndpoint("/v1/connect/intentions", []string{"GET", "POST"}, (*HTTPServer).IntentionEndpoint) registerEndpoint("/v1/connect/intentions/match", []string{"GET"}, (*HTTPServer).IntentionMatch) registerEndpoint("/v1/connect/intentions/check", []string{"GET"}, (*HTTPServer).IntentionCheck) + registerEndpoint("/v1/connect/intentions/exact", []string{"GET"}, (*HTTPServer).IntentionGetExact) registerEndpoint("/v1/connect/intentions/", []string{"GET", "PUT", "DELETE"}, (*HTTPServer).IntentionSpecific) registerEndpoint("/v1/coordinate/datacenters", []string{"GET"}, (*HTTPServer).CoordinateDatacenters) registerEndpoint("/v1/coordinate/nodes", []string{"GET"}, (*HTTPServer).CoordinateNodes) diff --git a/agent/intentions_endpoint.go b/agent/intentions_endpoint.go index d78b31c8e2..bb9a94b83a 100644 --- a/agent/intentions_endpoint.go +++ b/agent/intentions_endpoint.go @@ -9,7 +9,7 @@ import ( "github.com/hashicorp/consul/agent/structs" ) -// /v1/connection/intentions +// /v1/connect/intentions func (s *HTTPServer) IntentionEndpoint(resp http.ResponseWriter, req *http.Request) (interface{}, error) { switch req.Method { case "GET": @@ -32,6 +32,10 @@ func (s *HTTPServer) IntentionList(resp http.ResponseWriter, req *http.Request) return nil, nil } + if err := s.parseEntMeta(req, &args.EnterpriseMeta); err != nil { + return nil, err + } + var reply structs.IndexedIntentions defer setMeta(resp, &reply.QueryMeta) if err := s.agent.RPC("Intention.List", &args, &reply); err != nil { @@ -45,6 +49,11 @@ func (s *HTTPServer) IntentionList(resp http.ResponseWriter, req *http.Request) func (s *HTTPServer) IntentionCreate(resp http.ResponseWriter, req *http.Request) (interface{}, error) { // Method is tested in IntentionEndpoint + var entMeta structs.EnterpriseMeta + if err := s.parseEntMetaNoWildcard(req, &entMeta); err != nil { + return nil, err + } + args := structs.IntentionRequest{ Op: structs.IntentionOpCreate, } @@ -54,6 +63,12 @@ func (s *HTTPServer) IntentionCreate(resp http.ResponseWriter, req *http.Request return nil, fmt.Errorf("Failed to decode request body: %s", err) } + args.Intention.FillNonDefaultNamespaces(&entMeta) + + if err := s.validateEnterpriseIntention(args.Intention); err != nil { + return nil, err + } + var reply string if err := s.agent.RPC("Intention.Apply", &args, &reply); err != nil { return nil, err @@ -62,6 +77,16 @@ func (s *HTTPServer) IntentionCreate(resp http.ResponseWriter, req *http.Request return intentionCreateResponse{reply}, nil } +func (s *HTTPServer) validateEnterpriseIntention(ixn *structs.Intention) error { + if err := s.validateEnterpriseIntentionNamespace("SourceNS", ixn.SourceNS, true); err != nil { + return err + } + if err := s.validateEnterpriseIntentionNamespace("DestinationNS", ixn.DestinationNS, true); err != nil { + return err + } + return nil +} + // GET /v1/connect/intentions/match func (s *HTTPServer) IntentionMatch(resp http.ResponseWriter, req *http.Request) (interface{}, error) { // Prepare args @@ -70,6 +95,11 @@ func (s *HTTPServer) IntentionMatch(resp http.ResponseWriter, req *http.Request) return nil, nil } + var entMeta structs.EnterpriseMeta + if err := s.parseEntMetaNoWildcard(req, &entMeta); err != nil { + return nil, err + } + q := req.URL.Query() // Extract the "by" query parameter @@ -94,7 +124,7 @@ func (s *HTTPServer) IntentionMatch(resp http.ResponseWriter, req *http.Request) // order of the returned responses. args.Match.Entries = make([]structs.IntentionMatchEntry, len(names)) for i, n := range names { - entry, err := parseIntentionMatchEntry(n) + entry, err := parseIntentionMatchEntry(n, &entMeta) if err != nil { return nil, fmt.Errorf("name %q is invalid: %s", n, err) } @@ -129,6 +159,11 @@ func (s *HTTPServer) IntentionCheck(resp http.ResponseWriter, req *http.Request) return nil, nil } + var entMeta structs.EnterpriseMeta + if err := s.parseEntMetaNoWildcard(req, &entMeta); err != nil { + return nil, err + } + q := req.URL.Query() // Set the source type if set @@ -150,7 +185,7 @@ func (s *HTTPServer) IntentionCheck(resp http.ResponseWriter, req *http.Request) // We parse them the same way as matches to extract namespace/name args.Check.SourceName = source[0] if args.Check.SourceType == structs.IntentionSourceConsul { - entry, err := parseIntentionMatchEntry(source[0]) + entry, err := parseIntentionMatchEntry(source[0], &entMeta) if err != nil { return nil, fmt.Errorf("source %q is invalid: %s", source[0], err) } @@ -159,7 +194,7 @@ func (s *HTTPServer) IntentionCheck(resp http.ResponseWriter, req *http.Request) } // The destination is always in the Consul format - entry, err := parseIntentionMatchEntry(destination[0]) + entry, err := parseIntentionMatchEntry(destination[0], &entMeta) if err != nil { return nil, fmt.Errorf("destination %q is invalid: %s", destination[0], err) } @@ -174,7 +209,81 @@ func (s *HTTPServer) IntentionCheck(resp http.ResponseWriter, req *http.Request) return &reply, nil } -// IntentionSpecific handles the endpoint for /v1/connection/intentions/:id +// GET /v1/connect/intentions/exact +func (s *HTTPServer) IntentionGetExact(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + var entMeta structs.EnterpriseMeta + if err := s.parseEntMetaNoWildcard(req, &entMeta); err != nil { + return nil, err + } + + args := structs.IntentionQueryRequest{ + Exact: &structs.IntentionQueryExact{}, + } + if done := s.parse(resp, req, &args.Datacenter, &args.QueryOptions); done { + return nil, nil + } + + q := req.URL.Query() + + // Extract the source/destination + source, ok := q["source"] + if !ok || len(source) != 1 { + return nil, fmt.Errorf("required query parameter 'source' not set") + } + destination, ok := q["destination"] + if !ok || len(destination) != 1 { + return nil, fmt.Errorf("required query parameter 'destination' not set") + } + + { + entry, err := parseIntentionMatchEntry(source[0], &entMeta) + if err != nil { + return nil, fmt.Errorf("source %q is invalid: %s", source[0], err) + } + args.Exact.SourceNS = entry.Namespace + args.Exact.SourceName = entry.Name + } + + { + entry, err := parseIntentionMatchEntry(destination[0], &entMeta) + if err != nil { + return nil, fmt.Errorf("destination %q is invalid: %s", destination[0], err) + } + args.Exact.DestinationNS = entry.Namespace + args.Exact.DestinationName = entry.Name + } + + var reply structs.IndexedIntentions + if err := s.agent.RPC("Intention.Get", &args, &reply); err != nil { + // We have to check the string since the RPC sheds the error type + if err.Error() == consul.ErrIntentionNotFound.Error() { + resp.WriteHeader(http.StatusNotFound) + fmt.Fprint(resp, err.Error()) + return nil, nil + } + + // Not ideal, but there are a number of error scenarios that are not + // user error (400). We look for a specific case of invalid UUID + // to detect a parameter error and return a 400 response. The error + // is not a constant type or message, so we have to use strings.Contains + if strings.Contains(err.Error(), "UUID") { + return nil, BadRequestError{Reason: err.Error()} + } + + return nil, err + } + + // This shouldn't happen since the called API documents it shouldn't, + // but we check since the alternative if it happens is a panic. + if len(reply.Intentions) == 0 { + resp.WriteHeader(http.StatusNotFound) + return nil, nil + } + + return reply.Intentions[0], nil +} + +// IntentionSpecific handles the endpoint for /v1/connect/intentions/:id func (s *HTTPServer) IntentionSpecific(resp http.ResponseWriter, req *http.Request) (interface{}, error) { id := strings.TrimPrefix(req.URL.Path, "/v1/connect/intentions/") @@ -238,6 +347,11 @@ func (s *HTTPServer) IntentionSpecificGet(id string, resp http.ResponseWriter, r func (s *HTTPServer) IntentionSpecificUpdate(id string, resp http.ResponseWriter, req *http.Request) (interface{}, error) { // Method is tested in IntentionEndpoint + var entMeta structs.EnterpriseMeta + if err := s.parseEntMetaNoWildcard(req, &entMeta); err != nil { + return nil, err + } + args := structs.IntentionRequest{ Op: structs.IntentionOpUpdate, } @@ -247,6 +361,8 @@ func (s *HTTPServer) IntentionSpecificUpdate(id string, resp http.ResponseWriter return nil, BadRequestError{Reason: fmt.Sprintf("Request decode failed: %v", err)} } + args.Intention.FillNonDefaultNamespaces(&entMeta) + // Use the ID from the URL args.Intention.ID = id @@ -284,9 +400,9 @@ type intentionCreateResponse struct{ ID string } // parseIntentionMatchEntry parses the query parameter for an intention // match query entry. -func parseIntentionMatchEntry(input string) (structs.IntentionMatchEntry, error) { +func parseIntentionMatchEntry(input string, entMeta *structs.EnterpriseMeta) (structs.IntentionMatchEntry, error) { var result structs.IntentionMatchEntry - result.Namespace = structs.IntentionDefaultNamespace + result.Namespace = entMeta.NamespaceOrEmpty() // Get the index to the '/'. If it doesn't exist, we have just a name // so just set that and return. diff --git a/agent/intentions_endpoint_oss_test.go b/agent/intentions_endpoint_oss_test.go new file mode 100644 index 0000000000..2f333ee56a --- /dev/null +++ b/agent/intentions_endpoint_oss_test.go @@ -0,0 +1,48 @@ +// +build !consulent + +package agent + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/hashicorp/consul/agent/structs" + "github.com/stretchr/testify/require" +) + +func TestOSS_IntentionsCreate_failure(t *testing.T) { + t.Parallel() + + a := NewTestAgent(t, "") + defer a.Shutdown() + + doCreate := func(t *testing.T, srcNS, dstNS string) { + t.Helper() + args := structs.TestIntention(t) + args.SourceNS = srcNS + args.SourceName = "*" + args.DestinationNS = dstNS + args.DestinationName = "*" + req, _ := http.NewRequest("POST", "/v1/connect/intentions", jsonReader(args)) + resp := httptest.NewRecorder() + _, err := a.srv.IntentionCreate(resp, req) + require.Error(t, err) + } + + t.Run("wildcard source namespace", func(t *testing.T) { + doCreate(t, "*", "default") + }) + t.Run("wildcard destination namespace", func(t *testing.T) { + doCreate(t, "default", "*") + }) + t.Run("wildcard source and destination namespaces", func(t *testing.T) { + doCreate(t, "*", "*") + }) + t.Run("non-default source namespace", func(t *testing.T) { + doCreate(t, "foo", "default") + }) + t.Run("non-default destination namespace", func(t *testing.T) { + doCreate(t, "default", "foo") + }) +} diff --git a/agent/intentions_endpoint_test.go b/agent/intentions_endpoint_test.go index 4ca006a872..2f688f73da 100644 --- a/agent/intentions_endpoint_test.go +++ b/agent/intentions_endpoint_test.go @@ -71,20 +71,15 @@ func TestIntentionsList_values(t *testing.T) { func TestIntentionsMatch_basic(t *testing.T) { t.Parallel() - assert := assert.New(t) a := NewTestAgent(t, "") defer a.Shutdown() // Create some intentions { insert := [][]string{ - {"foo", "*", "foo", "*"}, - {"foo", "*", "foo", "bar"}, - {"foo", "*", "foo", "baz"}, // shouldn't match - {"foo", "*", "bar", "bar"}, // shouldn't match - {"foo", "*", "bar", "*"}, // shouldn't match - {"foo", "*", "*", "*"}, - {"bar", "*", "foo", "bar"}, // duplicate destination different source + {"default", "*", "default", "*"}, + {"default", "*", "default", "bar"}, + {"default", "*", "default", "baz"}, // shouldn't match } for _, v := range insert { @@ -100,28 +95,26 @@ func TestIntentionsMatch_basic(t *testing.T) { // Create var reply string - assert.Nil(a.RPC("Intention.Apply", &ixn, &reply)) + require.Nil(t, a.RPC("Intention.Apply", &ixn, &reply)) } } // Request req, _ := http.NewRequest("GET", - "/v1/connect/intentions/match?by=destination&name=foo/bar", nil) + "/v1/connect/intentions/match?by=destination&name=bar", nil) resp := httptest.NewRecorder() obj, err := a.srv.IntentionMatch(resp, req) - assert.Nil(err) + require.Nil(t, err) value := obj.(map[string]structs.Intentions) - assert.Len(value, 1) + require.Len(t, value, 1) var actual [][]string expected := [][]string{ - {"bar", "*", "foo", "bar"}, - {"foo", "*", "foo", "bar"}, - {"foo", "*", "foo", "*"}, - {"foo", "*", "*", "*"}, + {"default", "*", "default", "bar"}, + {"default", "*", "default", "*"}, } - for _, ixn := range value["foo/bar"] { + for _, ixn := range value["bar"] { actual = append(actual, []string{ ixn.SourceNS, ixn.SourceName, @@ -130,7 +123,7 @@ func TestIntentionsMatch_basic(t *testing.T) { }) } - assert.Equal(expected, actual) + require.Equal(t, expected, actual) } func TestIntentionsMatch_noBy(t *testing.T) { @@ -187,16 +180,14 @@ func TestIntentionsMatch_noName(t *testing.T) { func TestIntentionsCheck_basic(t *testing.T) { t.Parallel() - require := require.New(t) a := NewTestAgent(t, "") defer a.Shutdown() // Create some intentions { insert := [][]string{ - {"foo", "*", "foo", "*"}, - {"foo", "*", "foo", "bar"}, - {"bar", "*", "foo", "bar"}, + {"default", "*", "default", "baz"}, + {"default", "*", "default", "bar"}, } for _, v := range insert { @@ -213,30 +204,30 @@ func TestIntentionsCheck_basic(t *testing.T) { // Create var reply string - require.Nil(a.RPC("Intention.Apply", &ixn, &reply)) + require.NoError(t, a.RPC("Intention.Apply", &ixn, &reply)) } } // Request matching intention { req, _ := http.NewRequest("GET", - "/v1/connect/intentions/test?source=foo/bar&destination=foo/baz", nil) + "/v1/connect/intentions/test?source=bar&destination=baz", nil) resp := httptest.NewRecorder() obj, err := a.srv.IntentionCheck(resp, req) - require.Nil(err) + require.NoError(t, err) value := obj.(*structs.IntentionQueryCheckResponse) - require.False(value.Allowed) + require.False(t, value.Allowed) } // Request non-matching intention { req, _ := http.NewRequest("GET", - "/v1/connect/intentions/test?source=foo/bar&destination=bar/qux", nil) + "/v1/connect/intentions/test?source=bar&destination=qux", nil) resp := httptest.NewRecorder() obj, err := a.srv.IntentionCheck(resp, req) - require.Nil(err) + require.NoError(t, err) value := obj.(*structs.IntentionQueryCheckResponse) - require.True(value.Allowed) + require.True(t, value.Allowed) } } @@ -482,12 +473,10 @@ func TestParseIntentionMatchEntry(t *testing.T) { { "foo", structs.IntentionMatchEntry{ - Namespace: structs.IntentionDefaultNamespace, - Name: "foo", + Name: "foo", }, false, }, - { "foo/bar", structs.IntentionMatchEntry{ @@ -496,7 +485,6 @@ func TestParseIntentionMatchEntry(t *testing.T) { }, false, }, - { "foo/bar/baz", structs.IntentionMatchEntry{}, @@ -507,7 +495,8 @@ func TestParseIntentionMatchEntry(t *testing.T) { for _, tc := range cases { t.Run(tc.Input, func(t *testing.T) { assert := assert.New(t) - actual, err := parseIntentionMatchEntry(tc.Input) + var entMeta structs.EnterpriseMeta + actual, err := parseIntentionMatchEntry(tc.Input, &entMeta) assert.Equal(err != nil, tc.Err, err) if err != nil { return diff --git a/agent/structs/intention.go b/agent/structs/intention.go index 84a3101e5d..b6bb1d71f0 100644 --- a/agent/structs/intention.go +++ b/agent/structs/intention.go @@ -2,6 +2,7 @@ package structs import ( "encoding/binary" + "errors" "fmt" "sort" "strconv" @@ -85,6 +86,18 @@ type Intention struct { RaftIndex `bexpr:"-"` } +func (t *Intention) Clone() *Intention { + t2 := *t + if t.Meta != nil { + t2.Meta = make(map[string]string) + for k, v := range t.Meta { + t2.Meta[k] = v + } + } + t2.Hash = nil + return &t2 +} + func (t *Intention) UnmarshalJSON(data []byte) (err error) { type Alias Intention aux := &struct { @@ -246,6 +259,10 @@ func (ixn *Intention) CanRead(authz acl.Authorizer) bool { } var authzContext acl.AuthorizerContext + // Read access on either end of the intention allows you to read the + // complete intention. This is so that both ends can be aware of why + // something does or does not work. + if ixn.SourceName != "" { ixn.FillAuthzContext(&authzContext, false) if authz.IntentionRead(ixn.SourceName, &authzContext) == acl.Allow { @@ -431,6 +448,10 @@ type IntentionQueryRequest struct { // return allowed/deny based on an exact match. Check *IntentionQueryCheck + // Exact is non-nil if we're performing a lookup of an intention by its + // unique name instead of its ID. + Exact *IntentionQueryExact + // Options for queries QueryOptions } @@ -507,6 +528,31 @@ type IntentionQueryCheckResponse struct { Allowed bool } +// IntentionQueryExact holds the parameters for performing a lookup of an +// intention by its unique name instead of its ID. +type IntentionQueryExact struct { + SourceNS, SourceName string + DestinationNS, DestinationName string +} + +// Validate is used to ensure all 4 parameters are specified. +func (q *IntentionQueryExact) Validate() error { + var err error + if q.SourceNS == "" { + err = multierror.Append(err, errors.New("SourceNS is missing")) + } + if q.SourceName == "" { + err = multierror.Append(err, errors.New("SourceName is missing")) + } + if q.DestinationNS == "" { + err = multierror.Append(err, errors.New("DestinationNS is missing")) + } + if q.DestinationName == "" { + err = multierror.Append(err, errors.New("DestinationName is missing")) + } + return err +} + // IntentionPrecedenceSorter takes a list of intentions and sorts them // based on the match precedence rules for intentions. The intentions // closer to the head of the list have higher precedence. i.e. index 0 has diff --git a/agent/structs/intention_oss.go b/agent/structs/intention_oss.go index 8c57f6d3c4..e2ae21bbf8 100644 --- a/agent/structs/intention_oss.go +++ b/agent/structs/intention_oss.go @@ -38,3 +38,10 @@ func (ixn *Intention) DefaultNamespaces(_ *EnterpriseMeta) { ixn.DestinationNS = IntentionDefaultNamespace } } + +// FillNonDefaultNamespaces will populate the SourceNS and DestinationNS fields +// if they are empty with the proper defaults, but only if the proper defaults +// are themselves not "default". +func (ixn *Intention) FillNonDefaultNamespaces(_ *EnterpriseMeta) { + // do nothing +} diff --git a/agent/structs/structs_oss.go b/agent/structs/structs_oss.go index 742925180b..6ecb6783c0 100644 --- a/agent/structs/structs_oss.go +++ b/agent/structs/structs_oss.go @@ -46,6 +46,10 @@ func (m *EnterpriseMeta) NamespaceOrDefault() string { return IntentionDefaultNamespace } +func (m *EnterpriseMeta) NamespaceOrEmpty() string { + return "" +} + func EnterpriseMetaInitializer(_ string) EnterpriseMeta { return emptyEnterpriseMeta } diff --git a/api/connect_intention.go b/api/connect_intention.go index c7c36b6d27..723de5f67d 100644 --- a/api/connect_intention.go +++ b/api/connect_intention.go @@ -270,6 +270,39 @@ func (h *Connect) IntentionCheck(args *IntentionCheck, q *QueryOptions) (bool, * return out.Allowed, qm, nil } +// IntentionGetExact retrieves a single intention by its unique name instead of +// its ID. +func (h *Connect) IntentionGetExact(source, destination string, q *QueryOptions) (*Intention, *QueryMeta, error) { + r := h.c.newRequest("GET", "/v1/connect/intentions/exact") + r.setQueryOptions(q) + r.params.Set("source", source) + r.params.Set("destination", destination) + rtt, resp, err := h.c.doRequest(r) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + qm := &QueryMeta{} + parseQueryMeta(resp, qm) + qm.RequestTime = rtt + + if resp.StatusCode == 404 { + return nil, qm, nil + } else if resp.StatusCode != 200 { + var buf bytes.Buffer + io.Copy(&buf, resp.Body) + return nil, nil, fmt.Errorf( + "Unexpected response %d: %s", resp.StatusCode, buf.String()) + } + + var out Intention + if err := decodeBody(resp, &out); err != nil { + return nil, nil, err + } + return &out, qm, nil +} + // IntentionCreate will create a new intention. The ID in the given // structure must be empty and a generate ID will be returned on // success. diff --git a/api/connect_intention_test.go b/api/connect_intention_test.go index a9517c3781..456bc52601 100644 --- a/api/connect_intention_test.go +++ b/api/connect_intention_test.go @@ -41,7 +41,7 @@ func TestAPI_ConnectIntentionCreateListGetUpdateDelete(t *testing.T) { require.Equal(ixn, actual) // Update it - ixn.SourceNS = ixn.SourceNS + "-different" + ixn.SourceName = ixn.SourceName + "-different" _, err = connect.IntentionUpdate(ixn, nil) require.NoError(err) @@ -91,12 +91,9 @@ func TestAPI_ConnectIntentionMatch(t *testing.T) { // Create { insert := [][]string{ - {"foo", "*"}, - {"foo", "bar"}, - {"foo", "baz"}, // shouldn't match - {"bar", "bar"}, // shouldn't match - {"bar", "*"}, // shouldn't match - {"*", "*"}, + {"default", "*"}, + {"default", "bar"}, + {"default", "baz"}, // shouldn't match } for _, v := range insert { @@ -112,14 +109,17 @@ func TestAPI_ConnectIntentionMatch(t *testing.T) { // Match it result, _, err := connect.IntentionMatch(&IntentionMatch{ By: IntentionMatchDestination, - Names: []string{"foo/bar"}, + Names: []string{"bar"}, }, nil) require.Nil(err) require.Len(result, 1) var actual [][]string - expected := [][]string{{"foo", "bar"}, {"foo", "*"}, {"*", "*"}} - for _, ixn := range result["foo/bar"] { + expected := [][]string{ + {"default", "bar"}, + {"default", "*"}, + } + for _, ixn := range result["bar"] { actual = append(actual, []string{ixn.DestinationNS, ixn.DestinationName}) } @@ -138,7 +138,8 @@ func TestAPI_ConnectIntentionCheck(t *testing.T) { // Create { insert := [][]string{ - {"foo", "*", "foo", "bar"}, + {"default", "*", "default", "bar", "deny"}, + {"default", "foo", "default", "bar", "allow"}, } for _, v := range insert { @@ -147,39 +148,39 @@ func TestAPI_ConnectIntentionCheck(t *testing.T) { ixn.SourceName = v[1] ixn.DestinationNS = v[2] ixn.DestinationName = v[3] - ixn.Action = IntentionActionDeny + ixn.Action = IntentionAction(v[4]) id, _, err := connect.IntentionCreate(ixn, nil) require.Nil(err) require.NotEmpty(id) } } - // Match it + // Match the deny rule { result, _, err := connect.IntentionCheck(&IntentionCheck{ - Source: "foo/qux", - Destination: "foo/bar", + Source: "default/qux", + Destination: "default/bar", }, nil) - require.Nil(err) + require.NoError(err) require.False(result) } - // Match it (non-matching) + // Match the allow rule { result, _, err := connect.IntentionCheck(&IntentionCheck{ - Source: "bar/qux", - Destination: "foo/bar", + Source: "default/foo", + Destination: "default/bar", }, nil) - require.Nil(err) + require.NoError(err) require.True(result) } } func testIntention() *Intention { return &Intention{ - SourceNS: "eng", + SourceNS: "default", SourceName: "api", - DestinationNS: "eng", + DestinationNS: "default", DestinationName: "db", Precedence: 9, Action: IntentionActionAllow, diff --git a/command/connect/expose/expose.go b/command/connect/expose/expose.go index 1a5bce5a5f..05746f396b 100644 --- a/command/connect/expose/expose.go +++ b/command/connect/expose/expose.go @@ -10,7 +10,6 @@ import ( "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/command/flags" "github.com/hashicorp/consul/command/intention/create" - "github.com/hashicorp/consul/command/intention/finder" "github.com/mitchellh/cli" ) @@ -183,8 +182,7 @@ func (c *cmd) Run(args []string) int { c.UI.Output(fmt.Sprintf("Successfully updated config entry for ingress service %q", gateway)) // Check for an existing intention. - ixnFinder := finder.Finder{Client: client} - existing, err := ixnFinder.Find(c.ingressGateway, c.service) + existing, _, err := client.Connect().IntentionGetExact(c.ingressGateway, c.service, nil) if err != nil { c.UI.Error(fmt.Sprintf("Error looking up existing intention: %s", err)) return 1 diff --git a/command/intention/check/check.go b/command/intention/check/check.go index 8a14b0d188..8de0f263f1 100644 --- a/command/intention/check/check.go +++ b/command/intention/check/check.go @@ -31,6 +31,7 @@ func (c *cmd) init() { c.http = &flags.HTTPFlags{} flags.Merge(c.flags, c.http.ClientFlags()) flags.Merge(c.flags, c.http.ServerFlags()) + flags.Merge(c.flags, c.http.NamespaceFlags()) c.help = flags.Usage(help, c.flags) } diff --git a/command/intention/create/create.go b/command/intention/create/create.go index 66bdcb4b9c..9a6011ab1c 100644 --- a/command/intention/create/create.go +++ b/command/intention/create/create.go @@ -10,7 +10,7 @@ import ( "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/command/flags" - "github.com/hashicorp/consul/command/intention/finder" + "github.com/hashicorp/consul/command/intention" "github.com/mitchellh/cli" ) @@ -54,6 +54,7 @@ func (c *cmd) init() { c.http = &flags.HTTPFlags{} flags.Merge(c.flags, c.http.ClientFlags()) flags.Merge(c.flags, c.http.ServerFlags()) + flags.Merge(c.flags, c.http.NamespaceFlags()) c.help = flags.Usage(help, c.flags) } @@ -88,20 +89,21 @@ func (c *cmd) Run(args []string) int { return 1 } - // Create the finder in case we need it - find := &finder.Finder{Client: client} - // Go through and create each intention for _, ixn := range ixns { // If replace is set to true, then perform an update operation. if c.flagReplace { - oldIxn, err := find.Find(ixn.SourceString(), ixn.DestinationString()) + oldIxn, _, err := client.Connect().IntentionGetExact( + intention.FormatSource(ixn), + intention.FormatDestination(ixn), + nil, + ) if err != nil { c.UI.Error(fmt.Sprintf( "Error looking up intention for replacement with source %q "+ "and destination %q: %s", - ixn.SourceString(), - ixn.DestinationString(), + intention.FormatSource(ixn), + intention.FormatDestination(ixn), err)) return 1 } @@ -113,8 +115,8 @@ func (c *cmd) Run(args []string) int { c.UI.Error(fmt.Sprintf( "Error replacing intention with source %q "+ "and destination %q: %s", - ixn.SourceString(), - ixn.DestinationString(), + intention.FormatSource(ixn), + intention.FormatDestination(ixn), err)) return 1 } diff --git a/command/intention/delete/delete.go b/command/intention/delete/delete.go index d7a928d9c8..3d953697d1 100644 --- a/command/intention/delete/delete.go +++ b/command/intention/delete/delete.go @@ -31,6 +31,7 @@ func (c *cmd) init() { c.http = &flags.HTTPFlags{} flags.Merge(c.flags, c.http.ClientFlags()) flags.Merge(c.flags, c.http.ServerFlags()) + flags.Merge(c.flags, c.http.NamespaceFlags()) c.help = flags.Usage(help, c.flags) } @@ -47,8 +48,7 @@ func (c *cmd) Run(args []string) int { } // Get the intention ID to load - f := &finder.Finder{Client: client} - id, err := f.IDFromArgs(c.flags.Args()) + id, err := finder.IDFromArgs(client, c.flags.Args()) if err != nil { c.UI.Error(fmt.Sprintf("Error: %s", err)) return 1 diff --git a/command/intention/finder/finder.go b/command/intention/finder/finder.go index 099a2c5301..6f857b7647 100644 --- a/command/intention/finder/finder.go +++ b/command/intention/finder/finder.go @@ -2,38 +2,21 @@ package finder import ( "fmt" - "strings" - "sync" "github.com/hashicorp/consul/api" ) -// Finder finds intentions by a src/dst exact match. There is currently -// no direct API to do this so this struct downloads all intentions and -// caches them once, and searches in-memory for this. For now this works since -// even with a very large number of intentions, the size of the data gzipped -// over HTTP will be relatively small. -// -// The Finder will only download the intentions one time. This struct is -// not expected to be used over a long period of time. Though it may be -// reused multile times, the intentions list is only downloaded once. -type Finder struct { - // Client is the API client to use for any requests. - Client *api.Client - - lock sync.Mutex - ixns []*api.Intention // cached list of intentions -} - -// ID returns the intention ID for the given CLI args. An error is returned +// IDFromArgs returns the intention ID for the given CLI args. An error is returned // if args is not 1 or 2 elements. -func (f *Finder) IDFromArgs(args []string) (string, error) { +func IDFromArgs(client *api.Client, args []string) (string, error) { switch len(args) { case 1: return args[0], nil case 2: - ixn, err := f.Find(args[0], args[1]) + ixn, _, err := client.Connect().IntentionGetExact( + args[0], args[1], nil, + ) if err != nil { return "", err } @@ -49,44 +32,3 @@ func (f *Finder) IDFromArgs(args []string) (string, error) { return "", fmt.Errorf("command requires exactly 1 or 2 arguments") } } - -// Find finds the intention that matches the given src and dst. This will -// return nil when the result is not found. -func (f *Finder) Find(src, dst string) (*api.Intention, error) { - src = StripDefaultNS(src) - dst = StripDefaultNS(dst) - - f.lock.Lock() - defer f.lock.Unlock() - - // If the list of ixns is nil, then we haven't fetched yet, so fetch - if f.ixns == nil { - ixns, _, err := f.Client.Connect().Intentions(nil) - if err != nil { - return nil, err - } - - f.ixns = ixns - } - - // Go through the intentions and find an exact match - for _, ixn := range f.ixns { - if ixn.SourceString() == src && ixn.DestinationString() == dst { - return ixn, nil - } - } - - return nil, nil -} - -// StripDefaultNS strips the default namespace from an argument. For now, -// the API and lookups strip this value from string output so we strip it. -func StripDefaultNS(v string) string { - if idx := strings.IndexByte(v, '/'); idx > 0 { - if v[:idx] == api.IntentionDefaultNamespace { - return v[idx+1:] - } - } - - return v -} diff --git a/command/intention/finder/finder_test.go b/command/intention/finder/finder_test.go index 54af9e8e8c..3561a759b2 100644 --- a/command/intention/finder/finder_test.go +++ b/command/intention/finder/finder_test.go @@ -8,10 +8,9 @@ import ( "github.com/stretchr/testify/require" ) -func TestFinder(t *testing.T) { +func TestIDFromArgs(t *testing.T) { t.Parallel() - require := require.New(t) a := agent.NewTestAgent(t, ``) defer a.Shutdown() client := a.Client() @@ -20,30 +19,26 @@ func TestFinder(t *testing.T) { var ids []string { insert := [][]string{ - {"a", "b", "c", "d"}, + {"a", "b"}, } for _, v := range insert { ixn := &api.Intention{ - SourceNS: v[0], - SourceName: v[1], - DestinationNS: v[2], - DestinationName: v[3], + SourceName: v[0], + DestinationName: v[1], Action: api.IntentionActionAllow, } id, _, err := client.Connect().IntentionCreate(ixn, nil) - require.NoError(err) + require.NoError(t, err) ids = append(ids, id) } } - finder := &Finder{Client: client} - ixn, err := finder.Find("a/b", "c/d") - require.NoError(err) - require.Equal(ids[0], ixn.ID) + id, err := IDFromArgs(client, []string{"a", "b"}) + require.NoError(t, err) + require.Equal(t, ids[0], id) - ixn, err = finder.Find("a/c", "c/d") - require.NoError(err) - require.Nil(ixn) + _, err = IDFromArgs(client, []string{"c", "d"}) + require.Error(t, err) } diff --git a/command/intention/format.go b/command/intention/format.go new file mode 100644 index 0000000000..6e2146a9d7 --- /dev/null +++ b/command/intention/format.go @@ -0,0 +1,26 @@ +package intention + +import ( + "github.com/hashicorp/consul/api" +) + +// FormatSource returns the namespace/name format for the source. This is +// different from (*api.Intention).SourceString in that the default namespace +// is not omitted. +func FormatSource(i *api.Intention) string { + return partString(i.SourceNS, i.SourceName) +} + +// FormatDestination returns the namespace/name format for the destination. +// This is different from (*api.Intention).DestinationString in that the +// default namespace is not omitted. +func FormatDestination(i *api.Intention) string { + return partString(i.DestinationNS, i.DestinationName) +} + +func partString(ns, n string) string { + if ns == "" { + return n + } + return ns + "/" + n +} diff --git a/command/intention/get/get.go b/command/intention/get/get.go index cfe717a02c..0ca6d985e8 100644 --- a/command/intention/get/get.go +++ b/command/intention/get/get.go @@ -34,6 +34,7 @@ func (c *cmd) init() { c.http = &flags.HTTPFlags{} flags.Merge(c.flags, c.http.ClientFlags()) flags.Merge(c.flags, c.http.ServerFlags()) + flags.Merge(c.flags, c.http.NamespaceFlags()) c.help = flags.Usage(help, c.flags) } @@ -50,8 +51,7 @@ func (c *cmd) Run(args []string) int { } // Get the intention ID to load - f := &finder.Finder{Client: client} - id, err := f.IDFromArgs(c.flags.Args()) + id, err := finder.IDFromArgs(client, c.flags.Args()) if err != nil { c.UI.Error(fmt.Sprintf("Error: %s", err)) return 1 diff --git a/command/intention/match/match.go b/command/intention/match/match.go index 5ee6fb463f..c05e82bf1a 100644 --- a/command/intention/match/match.go +++ b/command/intention/match/match.go @@ -40,6 +40,7 @@ func (c *cmd) init() { c.http = &flags.HTTPFlags{} flags.Merge(c.flags, c.http.ClientFlags()) flags.Merge(c.flags, c.http.ServerFlags()) + flags.Merge(c.flags, c.http.NamespaceFlags()) c.help = flags.Usage(help, c.flags) } diff --git a/website/pages/api-docs/connect/intentions.mdx b/website/pages/api-docs/connect/intentions.mdx index a863f11852..d54c0e00f0 100644 --- a/website/pages/api-docs/connect/intentions.mdx +++ b/website/pages/api-docs/connect/intentions.mdx @@ -50,22 +50,36 @@ The table below shows this endpoint's support for For a `SourceType` of `consul` this is the name of a Consul service. The service doesn't need to be registered. +- `SourceNS` `(string: "")` - The namespace for the + `SourceName` parameter. + - `DestinationName` `(string: )` - The destination of the intention. The intention destination is always a Consul service, unlike the source. The service doesn't need to be registered. +- `DestinationNS` `(string: "")` - The namespace for the + `DestinationName` parameter. + - `SourceType` `(string: )` - The type for the `SourceName` value. This can be only "consul" today to represent a Consul service. - `Action` `(string: )` - This is one of "allow" or "deny" for the action that should be taken if this intention matches a request. -- `Description` `(string: nil)` - Description for the intention. This is not +- `Description` `(string: "")` - Description for the intention. This is not used for anything by Consul, but is presented in API responses to assist tooling. - `Meta` `(map: nil)` - Specifies arbitrary KV metadata pairs. +- `ns` `(string: "")` - Specifies the default + namespace to use when `SourceNS` or `DestinationNS` are omitted or empty. + If not provided at all, the default namespace will be inherited from the + request's ACL token or will default to the `default` namespace. + This value may be provided by either the `ns` URL query parameter or in the + `X-Consul-Namespace` header. + Added in Consul 1.9.0. + ### Sample Payload ```json @@ -154,6 +168,79 @@ $ curl \ } ``` +## Read Specific Intention by Name + +This endpoint reads a specific intention by its unique source and destination. + +| Method | Path | Produces | +| ------ | --------------------------- | ------------------ | +| `GET` | `/connect/intentions/exact` | `application/json` | + +The table below shows this endpoint's support for +[blocking queries](/api/features/blocking), +[consistency modes](/api/features/consistency), +[agent caching](/api/features/caching), and +[required ACLs](/api#authentication). + +| Blocking Queries | Consistency Modes | Agent Caching | ACL Required | +| ---------------- | ----------------- | ------------- | ----------------------------- | +| `YES` | `all` | `none` | `intentions:read`1 | + +

+ 1 Intention ACL rules are specified as part of a `service` rule. + See{' '} + + Intention Management Permissions + {' '} + for more details. +

+ +### Parameters + +- `source` `(string: )` - Specifies the source service. This + is specified as part of the URL. + This can take [several forms](/docs/commands/intention#source-and-destination-naming). + +- `destination` `(string: )` - Specifies the destination service. This + is specified as part of the URL. + This can take [several forms](/docs/commands/intention#source-and-destination-naming). + +- `ns` `(string: "")` - Specifies the default + namespace to use when `source` or `destination` parameters lack namespaces. + If not provided at all, the default namespace will be inherited from the + request's ACL token or will default to the `default` namespace. + This value may be provided by either the `ns` URL query parameter or in the + `X-Consul-Namespace` header. + Added in Consul 1.9.0. + +### Sample Request + +```shell-session +$ curl \ + http://127.0.0.1:8500/v1/connect/intentions/exact?source=web&destination=db +``` + +### Sample Response + +```json +{ + "ID": "e9ebc19f-d481-42b1-4871-4d298d3acd5c", + "Description": "", + "SourceNS": "default", + "SourceName": "web", + "DestinationNS": "default", + "DestinationName": "db", + "SourceType": "consul", + "Action": "allow", + "Meta": {}, + "Precedence": 9, + "CreatedAt": "2018-05-21T16:41:27.977155457Z", + "UpdatedAt": "2018-05-21T16:41:27.977157724Z", + "CreateIndex": 11, + "ModifyIndex": 11 +} +``` + ## List Intentions This endpoint lists all intentions. @@ -186,6 +273,15 @@ The table below shows this endpoint's support for - `filter` `(string: "")` - Specifies the expression used to filter the queries results prior to returning the data. +- `ns` `(string: "")` - Specifies the + namespace to list intentions from. + The `*` wildcard may be used to list intentions from all namespaces. + If not provided at all, the default namespace will be inherited from the + request's ACL token or will default to the `default` namespace. + This value may be provided by either the `ns` URL query parameter or in the + `X-Consul-Namespace` header. + Added in Consul 1.9.0. + ### Sample Request ```shell-session @@ -366,9 +462,19 @@ The table below shows this endpoint's support for - `source` `(string: )` - Specifies the source service. This is specified as part of the URL. + This can take [several forms](/docs/commands/intention#source-and-destination-naming). - `destination` `(string: )` - Specifies the destination service. This is specified as part of the URL. + This can take [several forms](/docs/commands/intention#source-and-destination-naming). + +- `ns` `(string: "")` - Specifies the default + namespace to use when `source` or `destination` parameters lack namespaces. + If not provided at all, the default namespace will be inherited from the + request's ACL token or will default to the `default` namespace. + This value may be provided by either the `ns` URL query parameter or in the + `X-Consul-Namespace` header. + Added in Consul 1.9.0. ### Sample Request @@ -422,6 +528,15 @@ The table below shows this endpoint's support for - `name` `(string: )` - Specifies a name to match. This parameter can be repeated for batching multiple matches. + This can take [several forms](/docs/commands/intention#source-and-destination-naming). + +- `ns` `(string: "")` - Specifies the default + namespace to use when `name` parameter lacks namespaces. + If not provided at all, the default namespace will be inherited from the + request's ACL token or will default to the `default` namespace. + This value may be provided by either the `ns` URL query parameter or in the + `X-Consul-Namespace` header. + Added in Consul 1.9.0. ### Sample Request diff --git a/website/pages/docs/commands/intention/check.mdx b/website/pages/docs/commands/intention/check.mdx index 18d9ff5e5f..263d782ab0 100644 --- a/website/pages/docs/commands/intention/check.mdx +++ b/website/pages/docs/commands/intention/check.mdx @@ -22,10 +22,16 @@ intention read permissions and don't evaluate the result. Usage: `consul intention check [options] SRC DST` +`SRC` and `DST` can both take [several forms](/docs/commands/intention#source-and-destination-naming). + #### API Options @include 'http_api_options_client.mdx' +#### Enterprise Options + +@include 'http_api_namespace_options.mdx' + ## Examples ```shell-session diff --git a/website/pages/docs/commands/intention/create.mdx b/website/pages/docs/commands/intention/create.mdx index aa3191c632..efb65040d9 100644 --- a/website/pages/docs/commands/intention/create.mdx +++ b/website/pages/docs/commands/intention/create.mdx @@ -15,10 +15,16 @@ The `intention create` command creates or updates an intention. Usage: `consul intention create [options] SRC DST` Usage: `consul intention create [options] -f FILE...` +`SRC` and `DST` can both take [several forms](/docs/commands/intention#source-and-destination-naming). + #### API Options @include 'http_api_options_client.mdx' +#### Enterprise Options + +@include 'http_api_namespace_options.mdx' + #### Intention Create Options - `-allow` - Set the action to "allow" for intentions. This is the default. diff --git a/website/pages/docs/commands/intention/delete.mdx b/website/pages/docs/commands/intention/delete.mdx index e47cadca23..9bf1f1aa03 100644 --- a/website/pages/docs/commands/intention/delete.mdx +++ b/website/pages/docs/commands/intention/delete.mdx @@ -17,10 +17,16 @@ Usage: - `consul intention delete [options] SRC DST` - `consul intention delete [options] ID` +`SRC` and `DST` can both take [several forms](/docs/commands/intention#source-and-destination-naming). + #### API Options @include 'http_api_options_client.mdx' +#### Enterprise Options + +@include 'http_api_namespace_options.mdx' + ## Examples Delete an intention from "web" to "db" with any action: diff --git a/website/pages/docs/commands/intention/get.mdx b/website/pages/docs/commands/intention/get.mdx index 853f874bf2..570eb72438 100644 --- a/website/pages/docs/commands/intention/get.mdx +++ b/website/pages/docs/commands/intention/get.mdx @@ -17,10 +17,16 @@ Usage: - `consul intention get [options] SRC DST` - `consul intention get [options] ID` +`SRC` and `DST` can both take [several forms](/docs/commands/intention#source-and-destination-naming). + #### API Options @include 'http_api_options_client.mdx' +#### Enterprise Options + +@include 'http_api_namespace_options.mdx' + ## Examples ```shell-session diff --git a/website/pages/docs/commands/intention/index.mdx b/website/pages/docs/commands/intention/index.mdx index bdc851513d..fae4235bb6 100644 --- a/website/pages/docs/commands/intention/index.mdx +++ b/website/pages/docs/commands/intention/index.mdx @@ -64,3 +64,16 @@ Find all intentions for communicating to the "db" service: ```shell-session $ consul intention match db ``` + +## Source and Destination Naming + +Intention commands commonly take positional arguments referred to as `SRC` and +`DST` in the command documentation. These can take several forms: + +| Format | Meaning | +| ----------------------- | -----------------------------------------------------------------------| +| `` | the named service in the current namespace | +| `*` | any service in the current namespace | +| `/` | the named service in a specific namespace | +| `/*` | any service in the specified namespace | +| `*/*` | any service in any namespace | diff --git a/website/pages/docs/commands/intention/match.mdx b/website/pages/docs/commands/intention/match.mdx index bd3b6080da..12edbc0ee5 100644 --- a/website/pages/docs/commands/intention/match.mdx +++ b/website/pages/docs/commands/intention/match.mdx @@ -19,10 +19,16 @@ check whether a connection would be authorized between any two services. Usage: `consul intention match [options] SRC_OR_DST` +`SRC` and `DST` can both take [several forms](/docs/commands/intention#source-and-destination-naming). + #### API Options @include 'http_api_options_client.mdx' +#### Enterprise Options + +@include 'http_api_namespace_options.mdx' + #### Intention Match Options - `-destination` - Match by destination.