Add FGA support to SHOW MEASUREMENTS

pull/9127/head
Edd Robinson 2017-11-15 15:48:23 +00:00
parent 5298339f21
commit 6851db3fc9
15 changed files with 166 additions and 61 deletions

View File

@ -742,7 +742,7 @@ func (e *StatementExecutor) executeShowMeasurementsStatement(q *influxql.ShowMea
return ErrDatabaseNameRequired
}
names, err := e.TSDBStore.MeasurementNames(q.Database, q.Condition)
names, err := e.TSDBStore.MeasurementNames(ctx.Authorizer, q.Database, q.Condition)
if err != nil || len(names) == 0 {
return ctx.Send(&query.Result{
StatementID: ctx.StatementID,
@ -1378,7 +1378,7 @@ type TSDBStore interface {
DeleteSeries(database string, sources []influxql.Source, condition influxql.Expr) error
DeleteShard(id uint64) error
MeasurementNames(database string, cond influxql.Expr) ([][]byte, error)
MeasurementNames(auth query.Authorizer, database string, cond influxql.Expr) ([][]byte, error)
TagKeys(auth query.Authorizer, shardIDs []uint64, cond influxql.Expr) ([]tsdb.TagKeys, error)
TagValues(auth query.Authorizer, shardIDs []uint64, cond influxql.Expr) ([]tsdb.TagValues, error)

View File

@ -280,7 +280,7 @@ func NewQueryExecutor() *QueryExecutor {
return nil
}
e.TSDBStore.MeasurementNamesFn = func(database string, cond influxql.Expr) ([][]byte, error) {
e.TSDBStore.MeasurementNamesFn = func(auth query.Authorizer, database string, cond influxql.Expr) ([][]byte, error) {
return nil, nil
}

View File

@ -28,7 +28,7 @@ type TSDBStoreMock struct {
ImportShardFn func(id uint64, r io.Reader) error
MeasurementSeriesCountsFn func(database string) (measuments int, series int)
MeasurementsCardinalityFn func(database string) (int64, error)
MeasurementNamesFn func(database string, cond influxql.Expr) ([][]byte, error)
MeasurementNamesFn func(auth query.Authorizer, database string, cond influxql.Expr) ([][]byte, error)
OpenFn func() error
PathFn func() string
RestoreShardFn func(id uint64, r io.Reader) error
@ -84,8 +84,8 @@ func (s *TSDBStoreMock) ExpandSources(sources influxql.Sources) (influxql.Source
func (s *TSDBStoreMock) ImportShard(id uint64, r io.Reader) error {
return s.ImportShardFn(id, r)
}
func (s *TSDBStoreMock) MeasurementNames(database string, cond influxql.Expr) ([][]byte, error) {
return s.MeasurementNamesFn(database, cond)
func (s *TSDBStoreMock) MeasurementNames(auth query.Authorizer, database string, cond influxql.Expr) ([][]byte, error) {
return s.MeasurementNamesFn(auth, database, cond)
}
func (s *TSDBStoreMock) MeasurementSeriesCounts(database string) (measuments int, series int) {
return s.MeasurementSeriesCountsFn(database)

View File

@ -133,7 +133,7 @@ func TestConcurrentServer_ShowMeasurements(t *testing.T) {
if !ok {
t.Fatal("Not a local server")
}
srv.TSDBStore.MeasurementNames("db0", nil)
srv.TSDBStore.MeasurementNames(query.OpenAuthorizer, "db0", nil)
}
runTest(10*time.Second, f1, f2)

View File

@ -59,7 +59,7 @@ type Engine interface {
SeriesN() int64
MeasurementExists(name []byte) (bool, error)
MeasurementNamesByExpr(expr influxql.Expr) ([][]byte, error)
MeasurementNamesByExpr(auth query.Authorizer, expr influxql.Expr) ([][]byte, error)
MeasurementNamesByRegex(re *regexp.Regexp) ([][]byte, error)
MeasurementFields(measurement []byte) *MeasurementFields
ForEachMeasurementName(fn func(name []byte) error) error

View File

@ -370,8 +370,8 @@ func (e *Engine) MeasurementExists(name []byte) (bool, error) {
return e.index.MeasurementExists(name)
}
func (e *Engine) MeasurementNamesByExpr(expr influxql.Expr) ([][]byte, error) {
return e.index.MeasurementNamesByExpr(expr)
func (e *Engine) MeasurementNamesByExpr(auth query.Authorizer, expr influxql.Expr) ([][]byte, error) {
return e.index.MeasurementNamesByExpr(auth, expr)
}
func (e *Engine) MeasurementNamesByRegex(re *regexp.Regexp) ([][]byte, error) {

View File

@ -19,7 +19,7 @@ type Index interface {
WithLogger(*zap.Logger)
MeasurementExists(name []byte) (bool, error)
MeasurementNamesByExpr(expr influxql.Expr) ([][]byte, error)
MeasurementNamesByExpr(auth query.Authorizer, expr influxql.Expr) ([][]byte, error)
MeasurementNamesByRegex(re *regexp.Regexp) ([][]byte, error)
DropMeasurement(name []byte) error
ForEachMeasurementName(fn func(name []byte) error) error

View File

@ -392,24 +392,26 @@ func (i *Index) TagsForSeries(key string) (models.Tags, error) {
// MeasurementNamesByExpr takes an expression containing only tags and returns a
// list of matching meaurement names.
func (i *Index) MeasurementNamesByExpr(expr influxql.Expr) ([][]byte, error) {
func (i *Index) MeasurementNamesByExpr(auth query.Authorizer, expr influxql.Expr) ([][]byte, error) {
i.mu.RLock()
defer i.mu.RUnlock()
// Return all measurement names if no expression is provided.
if expr == nil {
a := make([][]byte, 0, len(i.measurements))
for name := range i.measurements {
a = append(a, []byte(name))
for _, m := range i.measurements {
if m.Authorized(auth) {
a = append(a, m.name)
}
}
bytesutil.Sort(a)
return a, nil
}
return i.measurementNamesByExpr(expr)
return i.measurementNamesByExpr(auth, expr)
}
func (i *Index) measurementNamesByExpr(expr influxql.Expr) ([][]byte, error) {
func (i *Index) measurementNamesByExpr(auth query.Authorizer, expr influxql.Expr) ([][]byte, error) {
if expr == nil {
return nil, nil
}
@ -444,19 +446,19 @@ func (i *Index) measurementNamesByExpr(expr influxql.Expr) ([][]byte, error) {
// Match on name, if specified.
if tag.Val == "_name" {
return i.measurementNamesByNameFilter(tf.Op, tf.Value, tf.Regex), nil
return i.measurementNamesByNameFilter(auth, tf.Op, tf.Value, tf.Regex), nil
} else if influxql.IsSystemName(tag.Val) {
return nil, nil
}
return i.measurementNamesByTagFilters(tf), nil
return i.measurementNamesByTagFilters(auth, tf), nil
case influxql.OR, influxql.AND:
lhs, err := i.measurementNamesByExpr(e.LHS)
lhs, err := i.measurementNamesByExpr(auth, e.LHS)
if err != nil {
return nil, err
}
rhs, err := i.measurementNamesByExpr(e.RHS)
rhs, err := i.measurementNamesByExpr(auth, e.RHS)
if err != nil {
return nil, err
}
@ -469,13 +471,13 @@ func (i *Index) measurementNamesByExpr(expr influxql.Expr) ([][]byte, error) {
return nil, fmt.Errorf("invalid tag comparison operator")
}
case *influxql.ParenExpr:
return i.measurementNamesByExpr(e.Expr)
return i.measurementNamesByExpr(auth, e.Expr)
}
return nil, fmt.Errorf("%#v", expr)
}
// measurementNamesByNameFilter returns the sorted measurements matching a name.
func (i *Index) measurementNamesByNameFilter(op influxql.Token, val string, regex *regexp.Regexp) [][]byte {
func (i *Index) measurementNamesByNameFilter(auth query.Authorizer, op influxql.Token, val string, regex *regexp.Regexp) [][]byte {
var names [][]byte
for _, m := range i.measurements {
var matched bool
@ -490,17 +492,16 @@ func (i *Index) measurementNamesByNameFilter(op influxql.Token, val string, rege
matched = !regex.MatchString(m.Name)
}
if !matched {
continue
if matched && m.Authorized(auth) {
names = append(names, m.name)
}
names = append(names, []byte(m.Name))
}
bytesutil.Sort(names)
return names
}
// measurementNamesByTagFilters returns the sorted measurements matching the filters on tag values.
func (i *Index) measurementNamesByTagFilters(filter *TagFilter) [][]byte {
func (i *Index) measurementNamesByTagFilters(auth query.Authorizer, filter *TagFilter) [][]byte {
// Build a list of measurements matching the filters.
var names [][]byte
var tagMatch bool
@ -541,9 +542,8 @@ func (i *Index) measurementNamesByTagFilters(filter *TagFilter) [][]byte {
// True | False | False
// False | True | False
// False | False | True
if tagMatch == (filter.Op == influxql.EQ || filter.Op == influxql.EQREGEX) {
if tagMatch == (filter.Op == influxql.EQ || filter.Op == influxql.EQREGEX) && m.Authorized(auth) {
names = append(names, []byte(m.Name))
continue
}
}

View File

@ -52,6 +52,28 @@ func NewMeasurement(database, name string) *Measurement {
}
}
// Authorized determines if this Measurement is authorized to be read, according
// to the provided Authorizer. A measurement is authorized to be read if at
// least one series from the measurement is authorized to be read.
func (m *Measurement) Authorized(auth query.Authorizer) bool {
if auth == nil {
return true
}
// Note(edd): the cost of this check scales linearly with the number of series
// belonging to a measurement, which means it may become expensive when there
// are large numbers of series on a measurement.
//
// In the future we might want to push the set of series down into the
// authorizer, but that will require an API change.
for _, s := range m.SeriesByIDMap() {
if auth.AuthorizeSeriesRead(m.database, m.name, s.tags) {
return true
}
}
return false
}
func (m *Measurement) HasField(name string) bool {
m.mu.RLock()
_, hasField := m.fieldNames[name]

View File

@ -39,9 +39,9 @@ func NewFileSet(database string, levels []CompactionLevel, files []File) (*FileS
}
// Close closes all the files in the file set.
func (p FileSet) Close() error {
func (fs FileSet) Close() error {
var err error
for _, f := range p.files {
for _, f := range fs.files {
if e := f.Close(); e != nil && err == nil {
err = e
}
@ -535,10 +535,10 @@ func (fs *FileSet) matchTagValueNotEqualNotEmptySeriesIterator(name, key []byte,
)
}
func (fs *FileSet) MeasurementNamesByExpr(expr influxql.Expr) ([][]byte, error) {
func (fs *FileSet) MeasurementNamesByExpr(auth query.Authorizer, expr influxql.Expr) ([][]byte, error) {
// Return filtered list if expression exists.
if expr != nil {
return fs.measurementNamesByExpr(expr)
return fs.measurementNamesByExpr(auth, expr)
}
itr := fs.MeasurementIterator()
@ -549,12 +549,14 @@ func (fs *FileSet) MeasurementNamesByExpr(expr influxql.Expr) ([][]byte, error)
// Iterate over all measurements if no condition exists.
var names [][]byte
for e := itr.Next(); e != nil; e = itr.Next() {
names = append(names, e.Name())
if fs.measurementAuthorized(auth, e.Name()) {
names = append(names, e.Name())
}
}
return names, nil
}
func (fs *FileSet) measurementNamesByExpr(expr influxql.Expr) ([][]byte, error) {
func (fs *FileSet) measurementNamesByExpr(auth query.Authorizer, expr influxql.Expr) ([][]byte, error) {
if expr == nil {
return nil, nil
}
@ -587,19 +589,19 @@ func (fs *FileSet) measurementNamesByExpr(expr influxql.Expr) ([][]byte, error)
// Match on name, if specified.
if tag.Val == "_name" {
return fs.measurementNamesByNameFilter(e.Op, value, regex), nil
return fs.measurementNamesByNameFilter(auth, e.Op, value, regex), nil
} else if influxql.IsSystemName(tag.Val) {
return nil, nil
}
return fs.measurementNamesByTagFilter(e.Op, tag.Val, value, regex), nil
return fs.measurementNamesByTagFilter(auth, e.Op, tag.Val, value, regex), nil
case influxql.OR, influxql.AND:
lhs, err := fs.measurementNamesByExpr(e.LHS)
lhs, err := fs.measurementNamesByExpr(auth, e.LHS)
if err != nil {
return nil, err
}
rhs, err := fs.measurementNamesByExpr(e.RHS)
rhs, err := fs.measurementNamesByExpr(auth, e.RHS)
if err != nil {
return nil, err
}
@ -614,14 +616,14 @@ func (fs *FileSet) measurementNamesByExpr(expr influxql.Expr) ([][]byte, error)
}
case *influxql.ParenExpr:
return fs.measurementNamesByExpr(e.Expr)
return fs.measurementNamesByExpr(auth, e.Expr)
default:
return nil, fmt.Errorf("%#v", expr)
}
}
// measurementNamesByNameFilter returns matching measurement names in sorted order.
func (fs *FileSet) measurementNamesByNameFilter(op influxql.Token, val string, regex *regexp.Regexp) [][]byte {
func (fs *FileSet) measurementNamesByNameFilter(auth query.Authorizer, op influxql.Token, val string, regex *regexp.Regexp) [][]byte {
itr := fs.MeasurementIterator()
if itr == nil {
return nil
@ -641,7 +643,7 @@ func (fs *FileSet) measurementNamesByNameFilter(op influxql.Token, val string, r
matched = !regex.Match(e.Name())
}
if matched {
if matched && fs.measurementAuthorized(auth, e.Name()) {
names = append(names, e.Name())
}
}
@ -649,7 +651,7 @@ func (fs *FileSet) measurementNamesByNameFilter(op influxql.Token, val string, r
return names
}
func (fs *FileSet) measurementNamesByTagFilter(op influxql.Token, key, val string, regex *regexp.Regexp) [][]byte {
func (fs *FileSet) measurementNamesByTagFilter(auth query.Authorizer, op influxql.Token, key, val string, regex *regexp.Regexp) [][]byte {
var names [][]byte
mitr := fs.MeasurementIterator()
@ -687,7 +689,7 @@ func (fs *FileSet) measurementNamesByTagFilter(op influxql.Token, key, val strin
// True | False | False
// False | True | False
// False | False | True
if tagMatch == (op == influxql.EQ || op == influxql.EQREGEX) {
if tagMatch == (op == influxql.EQ || op == influxql.EQREGEX) && fs.measurementAuthorized(auth, me.Name()) {
names = append(names, me.Name())
continue
}
@ -697,6 +699,23 @@ func (fs *FileSet) measurementNamesByTagFilter(op influxql.Token, key, val strin
return names
}
// measurementAuthorized determines if the measurement is authorized to be read.
// A measurment is authorised to be read if at least one of the measurement's
// series is authorised to be read.
func (fs *FileSet) measurementAuthorized(auth query.Authorizer, name []byte) bool {
if auth == nil {
return true
}
sitr := fs.MeasurementSeriesIterator(name)
for series := sitr.Next(); series != nil; series = sitr.Next() {
if auth.AuthorizeSeriesRead(fs.database, name, series.Tags()) {
return true
}
}
return false
}
// HasSeries returns true if the series exists and is not tombstoned.
func (fs *FileSet) HasSeries(name []byte, tags models.Tags, buf []byte) bool {
for _, f := range fs.files {

View File

@ -400,11 +400,11 @@ func (i *Index) MeasurementExists(name []byte) (bool, error) {
return m != nil && !m.Deleted(), nil
}
func (i *Index) MeasurementNamesByExpr(expr influxql.Expr) ([][]byte, error) {
func (i *Index) MeasurementNamesByExpr(auth query.Authorizer, expr influxql.Expr) ([][]byte, error) {
fs := i.RetainFileSet()
defer fs.Release()
names, err := fs.MeasurementNamesByExpr(expr)
names, err := fs.MeasurementNamesByExpr(auth, expr)
// Clone byte slices since they will be used after the fileset is released.
return bytesutil.CloneSlice(names), err

View File

@ -139,7 +139,7 @@ func TestIndex_MeasurementNamesByExpr(t *testing.T) {
// Retrieve measurements by expression
idx.Run(t, func(t *testing.T) {
t.Run("EQ", func(t *testing.T) {
names, err := idx.MeasurementNamesByExpr(influxql.MustParseExpr(`region = 'west'`))
names, err := idx.MeasurementNamesByExpr(nil, influxql.MustParseExpr(`region = 'west'`))
if err != nil {
t.Fatal(err)
} else if !reflect.DeepEqual(names, [][]byte{[]byte("cpu"), []byte("mem")}) {
@ -148,7 +148,7 @@ func TestIndex_MeasurementNamesByExpr(t *testing.T) {
})
t.Run("NEQ", func(t *testing.T) {
names, err := idx.MeasurementNamesByExpr(influxql.MustParseExpr(`region != 'east'`))
names, err := idx.MeasurementNamesByExpr(nil, influxql.MustParseExpr(`region != 'east'`))
if err != nil {
t.Fatal(err)
} else if !reflect.DeepEqual(names, [][]byte{[]byte("disk"), []byte("mem")}) {
@ -157,7 +157,7 @@ func TestIndex_MeasurementNamesByExpr(t *testing.T) {
})
t.Run("EQREGEX", func(t *testing.T) {
names, err := idx.MeasurementNamesByExpr(influxql.MustParseExpr(`region =~ /east|west/`))
names, err := idx.MeasurementNamesByExpr(nil, influxql.MustParseExpr(`region =~ /east|west/`))
if err != nil {
t.Fatal(err)
} else if !reflect.DeepEqual(names, [][]byte{[]byte("cpu"), []byte("mem")}) {
@ -166,7 +166,7 @@ func TestIndex_MeasurementNamesByExpr(t *testing.T) {
})
t.Run("NEQREGEX", func(t *testing.T) {
names, err := idx.MeasurementNamesByExpr(influxql.MustParseExpr(`country !~ /^u/`))
names, err := idx.MeasurementNamesByExpr(nil, influxql.MustParseExpr(`country !~ /^u/`))
if err != nil {
t.Fatal(err)
} else if !reflect.DeepEqual(names, [][]byte{[]byte("cpu"), []byte("disk")}) {

View File

@ -741,12 +741,12 @@ func (s *Shard) MeasurementsSketches() (estimator.Sketch, estimator.Sketch, erro
// MeasurementNamesByExpr returns names of measurements matching the condition.
// If cond is nil then all measurement names are returned.
func (s *Shard) MeasurementNamesByExpr(cond influxql.Expr) ([][]byte, error) {
func (s *Shard) MeasurementNamesByExpr(auth query.Authorizer, cond influxql.Expr) ([][]byte, error) {
engine, err := s.engine()
if err != nil {
return nil, err
}
return engine.MeasurementNamesByExpr(cond)
return engine.MeasurementNamesByExpr(auth, cond)
}
// MeasurementNamesByRegex returns names of measurements matching the regular expression.
@ -1595,7 +1595,9 @@ func NewFieldKeysIterator(engine Engine, opt query.IteratorOptions) (query.Itera
itr := &fieldKeysIterator{engine: engine}
// Retrieve measurements from shard. Filter if condition specified.
names, err := engine.MeasurementNamesByExpr(opt.Condition)
//
// FGA is currently not supported when retrieving field keys.
names, err := engine.MeasurementNamesByExpr(query.OpenAuthorizer, opt.Condition)
if err != nil {
return nil, err
}
@ -1685,7 +1687,7 @@ type measurementKeyFunc func(name []byte) ([][]byte, error)
func newMeasurementKeysIterator(engine Engine, fn measurementKeyFunc, opt query.IteratorOptions) (*measurementKeysIterator, error) {
itr := &measurementKeysIterator{fn: fn}
names, err := engine.MeasurementNamesByExpr(opt.Condition)
names, err := engine.MeasurementNamesByExpr(opt.Authorizer, opt.Condition)
if err != nil {
return nil, err
}

View File

@ -956,7 +956,7 @@ func (s *Store) WriteToShard(shardID uint64, points []models.Point) error {
// MeasurementNames returns a slice of all measurements. Measurements accepts an
// optional condition expression. If cond is nil, then all measurements for the
// database will be returned.
func (s *Store) MeasurementNames(database string, cond influxql.Expr) ([][]byte, error) {
func (s *Store) MeasurementNames(auth query.Authorizer, database string, cond influxql.Expr) ([][]byte, error) {
s.mu.RLock()
shards := s.filterShards(byDatabase(database))
s.mu.RUnlock()
@ -974,7 +974,7 @@ func (s *Store) MeasurementNames(database string, cond influxql.Expr) ([][]byte,
set := make(map[string]struct{})
var names [][]byte
for _, sh := range shards {
a, err := sh.MeasurementNamesByExpr(cond)
a, err := sh.MeasurementNamesByExpr(auth, cond)
if err != nil {
return nil, err
}
@ -1074,7 +1074,9 @@ func (s *Store) TagKeys(auth query.Authorizer, shardIDs []uint64, cond influxql.
// Determine list of measurements.
nameSet := make(map[string]struct{})
for _, sh := range shards {
names, err := sh.MeasurementNamesByExpr(measurementExpr)
// Checking for authorisation can be done later on, when non-matching
// series might have been filtered out based on other conditions.
names, err := sh.MeasurementNamesByExpr(nil, measurementExpr)
if err != nil {
return nil, err
}
@ -1228,7 +1230,9 @@ func (s *Store) TagValues(auth query.Authorizer, shardIDs []uint64, cond influxq
var maxMeasurements int // Hint as to lower bound on number of measurements.
for _, sh := range shards {
// names will be sorted by MeasurementNamesByExpr.
names, err := sh.MeasurementNamesByExpr(measurementExpr)
// Authorisation can be done later one, when series may have been filtered
// out by other conditions.
names, err := sh.MeasurementNamesByExpr(nil, measurementExpr)
if err != nil {
return nil, err
}
@ -1495,7 +1499,7 @@ func (s *Store) monitorShards() {
// inmem shards share the same index instance so just use the first one to avoid
// allocating the same measurements repeatedly
first := shards[0]
names, err := first.MeasurementNamesByExpr(nil)
names, err := first.MeasurementNamesByExpr(nil, nil)
if err != nil {
s.Logger.Warn("cannot retrieve measurement names", zap.Error(err))
return nil

View File

@ -514,7 +514,7 @@ func TestStore_MeasurementNames_Deduplicate(t *testing.T) {
`cpu value=3 20`,
)
meas, err := s.MeasurementNames("db0", nil)
meas, err := s.MeasurementNames(query.OpenAuthorizer, "db0", nil)
if err != nil {
t.Fatalf("unexpected error with MeasurementNames: %v", err)
}
@ -555,7 +555,7 @@ func testStoreCardinalityTombstoning(t *testing.T, store *Store) {
}
// Delete all the series for each measurement.
mnames, err := store.MeasurementNames("db", nil)
mnames, err := store.MeasurementNames(query.OpenAuthorizer, "db", nil)
if err != nil {
t.Fatal(err)
}
@ -960,6 +960,64 @@ func TestStore_TagValues(t *testing.T) {
}
}
func TestStore_Measurements_Auth(t *testing.T) {
t.Parallel()
test := func(index string) error {
s := MustOpenStore(index)
defer s.Close()
// Create shard #0 with data.
s.MustCreateShardWithData("db0", "rp0", 0,
`cpu,host=serverA value=1 0`,
`cpu,host=serverA value=2 10`,
`cpu,region=west value=3 20`,
`cpu,secret=foo value=5 30`, // cpu still readable because it has other series that can be read.
`mem,secret=foo value=1 30`,
`disk value=4 30`,
)
authorizer := &internal.AuthorizerMock{
AuthorizeSeriesReadFn: func(database string, measurement []byte, tags models.Tags) bool {
if database == "" || tags.GetString("secret") != "" {
t.Logf("Rejecting series db=%s, m=%s, tags=%v", database, measurement, tags)
return false
}
return true
},
}
names, err := s.MeasurementNames(authorizer, "db0", nil)
if err != nil {
return err
}
// names should not contain any measurements where none of the associated
// series are authorised for reads.
expNames := 2
var gotNames int
for _, name := range names {
if string(name) == "mem" {
return fmt.Errorf("got measurment %q but it should be filtered.", name)
}
gotNames++
}
if gotNames != expNames {
return fmt.Errorf("got %d measurements, but expected %d", gotNames, expNames)
}
return nil
}
for _, index := range tsdb.RegisteredIndexes() {
t.Run(index, func(t *testing.T) {
if err := test(index); err != nil {
t.Fatal(err)
}
})
}
}
func TestStore_TagKeys_Auth(t *testing.T) {
t.Parallel()
@ -990,7 +1048,7 @@ func TestStore_TagKeys_Auth(t *testing.T) {
return err
}
// values should not contain any tag values associated with a series containing
// keys should not contain any tag keys associated with a series containing
// a secret tag.
expKeys := 3
var gotKeys int