package structs import ( "fmt" "sort" "strings" "time" "github.com/hashicorp/consul/acl" ) type ServiceIntentionsConfigEntry struct { Kind string Name string // formerly DestinationName Sources []*SourceIntention Meta map[string]string `json:",omitempty"` // formerly Intention.Meta EnterpriseMeta `hcl:",squash" mapstructure:",squash"` // formerly DestinationNS RaftIndex } var _ UpdatableConfigEntry = (*ServiceIntentionsConfigEntry)(nil) func (e *ServiceIntentionsConfigEntry) GetKind() string { return ServiceIntentions } func (e *ServiceIntentionsConfigEntry) GetName() string { if e == nil { return "" } return e.Name } func (e *ServiceIntentionsConfigEntry) GetMeta() map[string]string { if e == nil { return nil } return e.Meta } func (e *ServiceIntentionsConfigEntry) Clone() *ServiceIntentionsConfigEntry { e2 := *e e2.Meta = cloneStringStringMap(e.Meta) e2.Sources = make([]*SourceIntention, len(e.Sources)) for i, src := range e.Sources { e2.Sources[i] = src.Clone() } return &e2 } func (e *ServiceIntentionsConfigEntry) DestinationServiceName() ServiceName { return NewServiceName(e.Name, &e.EnterpriseMeta) } func (e *ServiceIntentionsConfigEntry) UpdateSourceByLegacyID(legacyID string, update *SourceIntention) bool { for i, src := range e.Sources { if src.LegacyID == legacyID { e.Sources[i] = update return true } } return false } func (e *ServiceIntentionsConfigEntry) UpsertSourceByName(sn ServiceName, upsert *SourceIntention) { for i, src := range e.Sources { if src.SourceServiceName() == sn { e.Sources[i] = upsert return } } e.Sources = append(e.Sources, upsert) } func (e *ServiceIntentionsConfigEntry) DeleteSourceByLegacyID(legacyID string) bool { for i, src := range e.Sources { if src.LegacyID == legacyID { // Delete slice element: https://github.com/golang/go/wiki/SliceTricks#delete // a = append(a[:i], a[i+1:]...) e.Sources = append(e.Sources[:i], e.Sources[i+1:]...) if len(e.Sources) == 0 { e.Sources = nil } return true } } return false } func (e *ServiceIntentionsConfigEntry) DeleteSourceByName(sn ServiceName) bool { for i, src := range e.Sources { if src.SourceServiceName() == sn { // Delete slice element: https://github.com/golang/go/wiki/SliceTricks#delete // a = append(a[:i], a[i+1:]...) e.Sources = append(e.Sources[:i], e.Sources[i+1:]...) if len(e.Sources) == 0 { e.Sources = nil } return true } } return false } func (e *ServiceIntentionsConfigEntry) ToIntention(src *SourceIntention) *Intention { meta := e.Meta if src.LegacyID != "" { meta = src.LegacyMeta } ixn := &Intention{ ID: src.LegacyID, Description: src.Description, SourceNS: src.NamespaceOrDefault(), SourceName: src.Name, SourceType: src.Type, Action: src.Action, Permissions: src.Permissions, Meta: meta, Precedence: src.Precedence, DestinationNS: e.NamespaceOrDefault(), DestinationName: e.Name, RaftIndex: e.RaftIndex, } if src.LegacyCreateTime != nil { ixn.CreatedAt = *src.LegacyCreateTime } if src.LegacyUpdateTime != nil { ixn.UpdatedAt = *src.LegacyUpdateTime } if src.LegacyID != "" { // Ensure that pre-1.9.0 secondaries can still replicate legacy // intentions via the APIs. These require the Hash field to be // populated. // //nolint:staticcheck ixn.SetHash() } return ixn } func (e *ServiceIntentionsConfigEntry) LegacyIDFieldsAreAllEmpty() bool { for _, src := range e.Sources { if src.LegacyID != "" { return false } } return true } func (e *ServiceIntentionsConfigEntry) LegacyIDFieldsAreAllSet() bool { for _, src := range e.Sources { if src.LegacyID == "" { return false } } return true } func (e *ServiceIntentionsConfigEntry) ToIntentions() Intentions { out := make(Intentions, 0, len(e.Sources)) for _, src := range e.Sources { out = append(out, e.ToIntention(src)) } return out } type SourceIntention struct { // Name is the name of the source service. This can be a wildcard "*", but // only the full value can be a wildcard. Partial wildcards are not // allowed. // // The source may also be a non-Consul service, as specified by SourceType. // // formerly Intention.SourceName Name string // Action is whether this is an allowlist or denylist intention. // // formerly Intention.Action // // NOTE: this is mutually exclusive with the Permissions field. Action IntentionAction `json:",omitempty"` // Permissions is the list of additional L7 attributes that extend the // intention definition. // // Permissions are interpreted in the order represented in the slice. In // default-deny mode, deny permissions are logically subtracted from all // following allow permissions. Multiple allow permissions are then ORed // together. // // For example: // ["deny /v2/admin", "allow /v2/*", "allow GET /healthz"] // // Is logically interpreted as: // allow: [ // "(/v2/*) AND NOT (/v2/admin)", // "(GET /healthz) AND NOT (/v2/admin)" // ] Permissions []*IntentionPermission `json:",omitempty"` // Precedence is the order that the intention will be applied, with // larger numbers being applied first. This is a read-only field, on // any intention update it is updated. // // Note we will technically decode this over the wire during a write, but // we always recompute it on save. // // formerly Intention.Precedence Precedence int // LegacyID is manipulated just by the bridging code // used as part of backwards compatibility. // // formerly Intention.ID LegacyID string `json:",omitempty" alias:"legacy_id"` // Type is the type of the value for the source. // // formerly Intention.SourceType Type IntentionSourceType // Description is a human-friendly description of this intention. // It is opaque to Consul and is only stored and transferred in API // requests. // // formerly Intention.Description Description string `json:",omitempty"` // LegacyMeta is arbitrary metadata associated with the intention. This is // opaque to Consul but is served in API responses. // // formerly Intention.Meta LegacyMeta map[string]string `json:",omitempty" alias:"legacy_meta"` // LegacyCreateTime is formerly Intention.CreatedAt LegacyCreateTime *time.Time `json:",omitempty" alias:"legacy_create_time"` // LegacyUpdateTime is formerly Intention.UpdatedAt LegacyUpdateTime *time.Time `json:",omitempty" alias:"legacy_update_time"` // Things like L7 rules or Sentinel rules could go here later. // formerly Intention.SourceNS EnterpriseMeta `hcl:",squash" mapstructure:",squash"` } type IntentionPermission struct { Action IntentionAction // required: allow|deny HTTP *IntentionHTTPPermission `json:",omitempty"` // If we have non-http match criteria for other protocols // in the future (gRPC, redis, etc) they can go here. // Support for edge-decoded JWTs would likely be configured // in a new top level section here. // If we ever add Sentinel support, this is one place we may // wish to add it. } func (p *IntentionPermission) Clone() *IntentionPermission { p2 := *p if p.HTTP != nil { p2.HTTP = p.HTTP.Clone() } return &p2 } type IntentionHTTPPermission struct { // PathExact, PathPrefix, and PathRegex are mutually exclusive. PathExact string `json:",omitempty" alias:"path_exact"` PathPrefix string `json:",omitempty" alias:"path_prefix"` PathRegex string `json:",omitempty" alias:"path_regex"` Header []IntentionHTTPHeaderPermission `json:",omitempty"` Methods []string `json:",omitempty"` } func (p *IntentionHTTPPermission) Clone() *IntentionHTTPPermission { p2 := *p if len(p.Header) > 0 { p2.Header = make([]IntentionHTTPHeaderPermission, 0, len(p.Header)) for _, hdr := range p.Header { p2.Header = append(p2.Header, hdr) } } p2.Methods = cloneStringSlice(p.Methods) return &p2 } type IntentionHTTPHeaderPermission struct { Name string Present bool `json:",omitempty"` Exact string `json:",omitempty"` Prefix string `json:",omitempty"` Suffix string `json:",omitempty"` Regex string `json:",omitempty"` Invert bool `json:",omitempty"` } func cloneStringStringMap(m map[string]string) map[string]string { if m == nil { return nil } m2 := make(map[string]string) for k, v := range m { m2[k] = v } return m2 } func (x *SourceIntention) SourceServiceName() ServiceName { return NewServiceName(x.Name, &x.EnterpriseMeta) } func (x *SourceIntention) Clone() *SourceIntention { x2 := *x x2.LegacyMeta = cloneStringStringMap(x.LegacyMeta) if len(x.Permissions) > 0 { x2.Permissions = make([]*IntentionPermission, 0, len(x.Permissions)) for _, perm := range x.Permissions { x2.Permissions = append(x2.Permissions, perm.Clone()) } } return &x2 } func (e *ServiceIntentionsConfigEntry) UpdateOver(rawPrev ConfigEntry) error { if rawPrev == nil { return nil } prev, ok := rawPrev.(*ServiceIntentionsConfigEntry) if !ok { return fmt.Errorf("previous config entry is not of type %T: %T", e, rawPrev) } var ( prevSourceByName = make(map[ServiceName]*SourceIntention) prevSourceByLegacyID = make(map[string]*SourceIntention) ) for _, src := range prev.Sources { prevSourceByName[src.SourceServiceName()] = src if src.LegacyID != "" { prevSourceByLegacyID[src.LegacyID] = src } } for i, src := range e.Sources { if src.LegacyID == "" { continue } // Check that the LegacyID fields are handled correctly during updates. if prevSrc, ok := prevSourceByName[src.SourceServiceName()]; ok { if prevSrc.LegacyID == "" { return fmt.Errorf("Sources[%d].LegacyID: cannot set this field", i) } else if src.LegacyID != prevSrc.LegacyID { return fmt.Errorf("Sources[%d].LegacyID: cannot set this field to a different value", i) } } // Now ensure legacy timestamps carry over properly. We always retain the LegacyCreateTime. if prevSrc, ok := prevSourceByLegacyID[src.LegacyID]; ok { if prevSrc.LegacyCreateTime != nil { // NOTE: we don't want to share the memory here src.LegacyCreateTime = timePointer(*prevSrc.LegacyCreateTime) } } } return nil } func (e *ServiceIntentionsConfigEntry) Normalize() error { return e.normalize(false) } func (e *ServiceIntentionsConfigEntry) LegacyNormalize() error { return e.normalize(true) } func (e *ServiceIntentionsConfigEntry) normalize(legacyWrite bool) error { if e == nil { return fmt.Errorf("config entry is nil") } e.Kind = ServiceIntentions e.EnterpriseMeta.Normalize() for _, src := range e.Sources { // Default source type if src.Type == "" { src.Type = IntentionSourceConsul } // If the source namespace is omitted it inherits that of the // destination. src.EnterpriseMeta.MergeNoWildcard(&e.EnterpriseMeta) src.EnterpriseMeta.Normalize() // Compute the precedence only AFTER normalizing namespaces since the // namespaces are factored into the calculation. src.Precedence = computeIntentionPrecedence(e, src) if legacyWrite { // We always force meta to be non-nil so that it's an empty map. This // makes it easy for API responses to not nil-check this everywhere. if src.LegacyMeta == nil { src.LegacyMeta = make(map[string]string) } // Set the created/updated times. If this is an update instead of an insert // the UpdateOver() will fix it up appropriately. now := time.Now().UTC() src.LegacyCreateTime = timePointer(now) src.LegacyUpdateTime = timePointer(now) } else { // Legacy fields are cleared, except LegacyMeta which we leave // populated so that we can later fail the write in Validate() and // give the user a warning about possible data loss. src.LegacyID = "" src.LegacyCreateTime = nil src.LegacyUpdateTime = nil } for _, perm := range src.Permissions { if perm.HTTP == nil { continue } for j := 0; j < len(perm.HTTP.Methods); j++ { perm.HTTP.Methods[j] = strings.ToUpper(perm.HTTP.Methods[j]) } } } // The source intentions closer to the head of the list have higher // precedence. i.e. index 0 has the highest precedence. sort.SliceStable(e.Sources, func(i, j int) bool { return e.Sources[i].Precedence > e.Sources[j].Precedence }) return nil } func timePointer(t time.Time) *time.Time { if t.IsZero() { return nil } return &t } // NOTE: this assumes that the namespaces have been fully normalized. func computeIntentionPrecedence(entry *ServiceIntentionsConfigEntry, src *SourceIntention) int { // Max maintains the maximum value that the precedence can be depending // on the number of exact values in the destination. var max int switch intentionCountExact(entry.Name, &entry.EnterpriseMeta) { case 2: max = 9 case 1: max = 6 case 0: max = 3 default: // This shouldn't be possible, just set it to zero return 0 } // Given the maximum, the exact value is determined based on the // number of source exact values. countSrc := intentionCountExact(src.Name, &src.EnterpriseMeta) return max - (2 - countSrc) } // intentionCountExact counts the number of exact values (not wildcards) in // the given namespace and name. func intentionCountExact(name string, entMeta *EnterpriseMeta) int { ns := entMeta.NamespaceOrDefault() // If NS is wildcard, pair must be */* since an exact service cannot follow a wildcard NS // */* is allowed, but */foo is not if ns == WildcardSpecifier { return 0 } // only the namespace must be exact, since the */* case already returned. if name == WildcardSpecifier { return 1 } return 2 } func (e *ServiceIntentionsConfigEntry) Validate() error { return e.validate(false) } func (e *ServiceIntentionsConfigEntry) LegacyValidate() error { return e.validate(true) } func (e *ServiceIntentionsConfigEntry) HasWildcardDestination() bool { dstNS := e.EnterpriseMeta.NamespaceOrDefault() return dstNS == WildcardSpecifier || e.Name == WildcardSpecifier } func (e *ServiceIntentionsConfigEntry) HasAnyPermissions() bool { for _, src := range e.Sources { if len(src.Permissions) > 0 { return true } } return false } func (e *ServiceIntentionsConfigEntry) validate(legacyWrite bool) error { if e.Name == "" { return fmt.Errorf("Name is required") } if err := validateIntentionWildcards(e.Name, &e.EnterpriseMeta); err != nil { return err } destIsWild := e.HasWildcardDestination() if legacyWrite { if len(e.Meta) > 0 { return fmt.Errorf("Meta must be omitted for legacy intention writes") } } else { if err := validateConfigEntryMeta(e.Meta); err != nil { return err } } if len(e.Sources) == 0 { return fmt.Errorf("At least one source is required") } seenSources := make(map[ServiceName]struct{}) for i, src := range e.Sources { if src.Name == "" { return fmt.Errorf("Sources[%d].Name is required", i) } if err := validateIntentionWildcards(src.Name, &src.EnterpriseMeta); err != nil { return fmt.Errorf("Sources[%d].%v", i, err) } // Length of opaque values if len(src.Description) > metaValueMaxLength { return fmt.Errorf( "Sources[%d].Description exceeds maximum length %d", i, metaValueMaxLength) } if legacyWrite { if len(src.LegacyMeta) > metaMaxKeyPairs { return fmt.Errorf( "Sources[%d].Meta exceeds maximum element count %d", i, metaMaxKeyPairs) } for k, v := range src.LegacyMeta { if len(k) > metaKeyMaxLength { return fmt.Errorf( "Sources[%d].Meta key %q exceeds maximum length %d", i, k, metaKeyMaxLength, ) } if len(v) > metaValueMaxLength { return fmt.Errorf( "Sources[%d].Meta value for key %q exceeds maximum length %d", i, k, metaValueMaxLength, ) } } } else { if len(src.LegacyMeta) > 0 { return fmt.Errorf("Sources[%d].LegacyMeta must be omitted", i) } src.LegacyMeta = nil // ensure it's completely unset } if legacyWrite { if src.LegacyID == "" { return fmt.Errorf("Sources[%d].LegacyID must be set", i) } } else { if src.LegacyID != "" { return fmt.Errorf("Sources[%d].LegacyID must be omitted", i) } } if legacyWrite || len(src.Permissions) == 0 { switch src.Action { case IntentionActionAllow, IntentionActionDeny: default: return fmt.Errorf("Sources[%d].Action must be set to 'allow' or 'deny'", i) } } if len(src.Permissions) > 0 && src.Action != "" { return fmt.Errorf("Sources[%d].Action must be omitted if Permissions are specified", i) } if destIsWild && len(src.Permissions) > 0 { return fmt.Errorf("Sources[%d].Permissions cannot be specified on intentions with wildcarded destinations", i) } switch src.Type { case IntentionSourceConsul: default: return fmt.Errorf("Sources[%d].Type must be set to 'consul'", i) } for j, perm := range src.Permissions { switch perm.Action { case IntentionActionAllow, IntentionActionDeny: default: return fmt.Errorf("Sources[%d].Permissions[%d].Action must be set to 'allow' or 'deny'", i, j) } errorPrefix := "Sources[%d].Permissions[%d].HTTP" if perm.HTTP == nil { return fmt.Errorf(errorPrefix+" is required", i, j) } pathParts := 0 if perm.HTTP.PathExact != "" { pathParts++ if !strings.HasPrefix(perm.HTTP.PathExact, "/") { return fmt.Errorf( errorPrefix+".PathExact doesn't start with '/': %q", i, j, perm.HTTP.PathExact, ) } } if perm.HTTP.PathPrefix != "" { pathParts++ if !strings.HasPrefix(perm.HTTP.PathPrefix, "/") { return fmt.Errorf( errorPrefix+".PathPrefix doesn't start with '/': %q", i, j, perm.HTTP.PathPrefix, ) } } if perm.HTTP.PathRegex != "" { pathParts++ } if pathParts > 1 { return fmt.Errorf( errorPrefix+" should only contain at most one of PathExact, PathPrefix, or PathRegex", i, j, ) } permParts := pathParts for k, hdr := range perm.HTTP.Header { if hdr.Name == "" { return fmt.Errorf(errorPrefix+".Header[%d] missing required Name field", i, j, k) } hdrParts := 0 if hdr.Present { hdrParts++ } if hdr.Exact != "" { hdrParts++ } if hdr.Regex != "" { hdrParts++ } if hdr.Prefix != "" { hdrParts++ } if hdr.Suffix != "" { hdrParts++ } if hdrParts != 1 { return fmt.Errorf(errorPrefix+".Header[%d] should only contain one of Present, Exact, Prefix, Suffix, or Regex", i, j, k) } permParts++ } if len(perm.HTTP.Methods) > 0 { found := make(map[string]struct{}) for _, m := range perm.HTTP.Methods { if !isValidHTTPMethod(m) { return fmt.Errorf(errorPrefix+".Methods contains an invalid method %q", i, j, m) } if _, ok := found[m]; ok { return fmt.Errorf(errorPrefix+".Methods contains %q more than once", i, j, m) } found[m] = struct{}{} } permParts++ } if permParts == 0 { return fmt.Errorf(errorPrefix+" should not be empty", i, j) } } serviceName := src.SourceServiceName() if _, exists := seenSources[serviceName]; exists { return fmt.Errorf("Sources[%d] defines %q more than once", i, serviceName.String()) } seenSources[serviceName] = struct{}{} } return nil } // Wildcard usage verification func validateIntentionWildcards(name string, entMeta *EnterpriseMeta) error { ns := entMeta.NamespaceOrDefault() if ns != WildcardSpecifier { if strings.Contains(ns, WildcardSpecifier) { return fmt.Errorf("Namespace: wildcard character '*' cannot be used with partial values") } } if name != WildcardSpecifier { if strings.Contains(name, WildcardSpecifier) { return fmt.Errorf("Name: wildcard character '*' cannot be used with partial values") } if ns == WildcardSpecifier { return fmt.Errorf("Name: exact value cannot follow wildcard namespace") } } return nil } func (e *ServiceIntentionsConfigEntry) GetRaftIndex() *RaftIndex { if e == nil { return &RaftIndex{} } return &e.RaftIndex } func (e *ServiceIntentionsConfigEntry) GetEnterpriseMeta() *EnterpriseMeta { if e == nil { return nil } return &e.EnterpriseMeta } func (e *ServiceIntentionsConfigEntry) CanRead(authz acl.Authorizer) bool { var authzContext acl.AuthorizerContext e.FillAuthzContext(&authzContext) return authz.IntentionRead(e.GetName(), &authzContext) == acl.Allow } func (e *ServiceIntentionsConfigEntry) CanWrite(authz acl.Authorizer) bool { var authzContext acl.AuthorizerContext e.FillAuthzContext(&authzContext) return authz.IntentionWrite(e.GetName(), &authzContext) == acl.Allow } func MigrateIntentions(ixns Intentions) []*ServiceIntentionsConfigEntry { if len(ixns) == 0 { return nil } collated := make(map[ServiceName]*ServiceIntentionsConfigEntry) for _, ixn := range ixns { thisEntry := ixn.ToConfigEntry(true) sn := thisEntry.DestinationServiceName() if entry, ok := collated[sn]; ok { entry.Sources = append(entry.Sources, thisEntry.Sources...) } else { collated[sn] = thisEntry } } out := make([]*ServiceIntentionsConfigEntry, 0, len(collated)) for _, entry := range collated { out = append(out, entry) } return out }