diff --git a/lib/auth/dbobjectimportrule/dbobjectimportrulev1/service.go b/lib/auth/dbobjectimportrule/dbobjectimportrulev1/service.go index 9dcbd75a88380..6f6503bfc25aa 100644 --- a/lib/auth/dbobjectimportrule/dbobjectimportrulev1/service.go +++ b/lib/auth/dbobjectimportrule/dbobjectimportrulev1/service.go @@ -27,9 +27,9 @@ import ( pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobjectimportrule/v1" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/types/databaseobjectimportrule" "github.com/gravitational/teleport/lib/authz" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/srv/db/common/databaseobjectimportrule" ) // Backend interface for manipulating DatabaseObjectImportRule resources. diff --git a/lib/auth/init.go b/lib/auth/init.go index 7ccaab4a30730..4c3856f1adab3 100644 --- a/lib/auth/init.go +++ b/lib/auth/init.go @@ -62,7 +62,7 @@ import ( "github.com/gravitational/teleport/lib/modules" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/services/local" - "github.com/gravitational/teleport/lib/srv/db/common/permissions" + "github.com/gravitational/teleport/lib/srv/db/common/databaseobjectimportrule" "github.com/gravitational/teleport/lib/sshca" "github.com/gravitational/teleport/lib/tlsca" usagereporter "github.com/gravitational/teleport/lib/usagereporter/teleport" @@ -941,7 +941,7 @@ func createPresetDatabaseObjectImportRule(ctx context.Context, rules services.Da return nil } - rule := permissions.NewPresetImportAllObjectsRule() + rule := databaseobjectimportrule.NewPresetImportAllObjectsRule() if rule == nil { return nil } diff --git a/lib/services/databaseobjectimportrule_test.go b/lib/services/databaseobjectimportrule_test.go index 3762ba2486eff..97f82aece1ba6 100644 --- a/lib/services/databaseobjectimportrule_test.go +++ b/lib/services/databaseobjectimportrule_test.go @@ -22,8 +22,10 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" + "github.com/gravitational/teleport/api/defaults" dbobjectimportrulev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobjectimportrule/v1" - "github.com/gravitational/teleport/api/types/databaseobjectimportrule" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + "github.com/gravitational/teleport/api/types" apilabels "github.com/gravitational/teleport/api/types/label" ) @@ -49,8 +51,15 @@ func TestMarshalDatabaseObjectImportRuleRoundTrip(t *testing.T) { }, }, } - obj, err := databaseobjectimportrule.NewDatabaseObjectImportRule("import_all_staging_tables", spec) - require.NoError(t, err) + obj := &dbobjectimportrulev1.DatabaseObjectImportRule{ + Kind: types.KindDatabaseObjectImportRule, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "import_all_staging_tables", + Namespace: defaults.Namespace, + }, + Spec: spec, + } out, err := MarshalDatabaseObjectImportRule(obj) require.NoError(t, err) diff --git a/lib/services/local/databaseobjectimportrule_test.go b/lib/services/local/databaseobjectimportrule_test.go index 67234f2c513cc..4c86eb3bded68 100644 --- a/lib/services/local/databaseobjectimportrule_test.go +++ b/lib/services/local/databaseobjectimportrule_test.go @@ -30,9 +30,9 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" databaseobjectimportrulev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobjectimportrule/v1" - "github.com/gravitational/teleport/api/types/databaseobjectimportrule" "github.com/gravitational/teleport/api/types/label" "github.com/gravitational/teleport/lib/backend/memory" + "github.com/gravitational/teleport/lib/srv/db/common/databaseobjectimportrule" ) // TestDatabaseObjectImportRuleCRUD tests backend operations with DatabaseObject import rule resources. diff --git a/lib/srv/db/autousers_test.go b/lib/srv/db/autousers_test.go index 37c132b14ba43..8752823c660cf 100644 --- a/lib/srv/db/autousers_test.go +++ b/lib/srv/db/autousers_test.go @@ -28,10 +28,10 @@ import ( dbobjectimportrulev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobjectimportrule/v1" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/types/databaseobjectimportrule" "github.com/gravitational/teleport/api/types/label" apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/srv/db/common/databaseobjectimportrule" "github.com/gravitational/teleport/lib/srv/db/mongodb" "github.com/gravitational/teleport/lib/srv/db/postgres" ) diff --git a/api/types/databaseobject/databaseobject.go b/lib/srv/db/common/databaseobject/databaseobject.go similarity index 100% rename from api/types/databaseobject/databaseobject.go rename to lib/srv/db/common/databaseobject/databaseobject.go diff --git a/api/types/databaseobject/databaseobject_test.go b/lib/srv/db/common/databaseobject/databaseobject_test.go similarity index 100% rename from api/types/databaseobject/databaseobject_test.go rename to lib/srv/db/common/databaseobject/databaseobject_test.go diff --git a/lib/srv/db/common/databaseobjectimportrule/apply.go b/lib/srv/db/common/databaseobjectimportrule/apply.go new file mode 100644 index 0000000000000..de180cd3014b1 --- /dev/null +++ b/lib/srv/db/common/databaseobjectimportrule/apply.go @@ -0,0 +1,281 @@ +// Teleport +// Copyright (C) 2023 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package databaseobjectimportrule + +import ( + "regexp" + "sort" + "strings" + "unicode" + + "github.com/gravitational/trace" + "github.com/sirupsen/logrus" + + dbobjectv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobject/v1" + dbobjectimportrulev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobjectimportrule/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/label" + "github.com/gravitational/teleport/api/utils" + "github.com/gravitational/teleport/lib/services" + libutils "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/lib/utils/typical" +) + +// ApplyDatabaseObjectImportRules applies the given set of rules onto a set of objects coming from a same database. +// Returns a fresh copy of a subset of supplied objects, filtered and modified. +// For the object to be returned, it must match at least one rule. +// The modification consists of application of extra labels, per matching mappings. +// If there are any errors due to invalid label template, the corresponding objects will be dropped. +// Final error count is returned. +func ApplyDatabaseObjectImportRules(logger logrus.FieldLogger, rules []*dbobjectimportrulev1.DatabaseObjectImportRule, database types.Database, objs []*dbobjectv1.DatabaseObject) ([]*dbobjectv1.DatabaseObject, int) { + // sort: rules with higher priorities are applied last. + sort.Slice(rules, func(i, j int) bool { + return rules[i].Spec.Priority < rules[j].Spec.Priority + }) + + // filter rules: keep those with matching labels + // we only need mappings from the rules, so extract those. + var mappings []*dbobjectimportrulev1.DatabaseObjectImportRuleMapping + for _, rule := range rules { + dbLabels := make(types.Labels) + mapLabel := label.ToMap(rule.Spec.GetDatabaseLabels()) + for k, v := range mapLabel { + dbLabels[k] = v + } + if ok, _, _ := services.MatchLabels(dbLabels, database.GetAllLabels()); ok { + mappings = append(mappings, rule.Spec.Mappings...) + } + } + + var objects []*dbobjectv1.DatabaseObject + var errCount int + // anything to do? + if len(mappings) == 0 { + return objects, errCount + } + + // find all objects that match any of the rules + for _, obj := range objs { + // prepare object clone + objClone := utils.CloneProtoMsg(obj) + if objClone.Metadata.Labels == nil { + objClone.Metadata.Labels = map[string]string{} + } + + // apply each mapping in order. + matched := false + hadError := false + for _, mapping := range mappings { + match, err := applyMappingToObject(mapping, objClone.GetSpec(), objClone.Metadata.Labels) + if err != nil { + logger.WithField("name", obj.GetMetadata().GetName()).WithError(err).Debug("failed to apply label due to template error") + errCount++ + hadError = true + break + } + if match { + matched = true + } + } + + if !hadError && matched { + objects = append(objects, objClone) + } + } + + return objects, errCount +} + +// validateTemplate evaluates the template, checking for potential errors. +func validateTemplate(template string) error { + _, err := evalTemplate(template, &dbobjectv1.DatabaseObjectSpec{}) + return trace.Wrap(err) +} + +type eval interface { + eval(spec *dbobjectv1.DatabaseObjectSpec) (string, error) +} + +type literal struct { + text string +} + +func (l literal) eval(_ *dbobjectv1.DatabaseObjectSpec) (string, error) { + return l.text, nil +} + +type expression struct { + text string +} + +func (e expression) eval(spec *dbobjectv1.DatabaseObjectSpec) (string, error) { + type evaluationEnv struct{} + + envVar := map[string]typical.Variable{ + "true": true, + "false": false, + "obj": typical.DynamicMapFunction(func(e evaluationEnv, key string) (string, error) { + switch key { + case "protocol": + return spec.GetProtocol(), nil + case "database_service_name": + return spec.GetDatabaseServiceName(), nil + case "object_kind": + return spec.GetObjectKind(), nil + case "database": + return spec.GetDatabase(), nil + case "schema": + return spec.GetSchema(), nil + case "name": + return spec.GetName(), nil + } + + return "", trace.NotFound("key %v not found", key) + }), + } + + parser, err := typical.NewParser[evaluationEnv, string](typical.ParserSpec{Variables: envVar}) + if err != nil { + return "", trace.Wrap(err) + } + + expr, err := parser.Parse(e.text) + if err != nil { + return "", trace.Wrap(err) + } + + text, err := expr.Evaluate(evaluationEnv{}) + if err != nil { + return "", trace.Wrap(err) + } + + return text, nil +} + +var reVariable = regexp.MustCompile( + // prefix is anything that is not { or } + `^(?P[^}{]*)` + + // variable is anything in brackets {{}} that is not { or } + `{{(?P\s*[^}{]*\s*)}}` + + // suffix is anything that is not { or } + `(?P[^}{]*)$`, +) + +// splitExpression splits the template into several parts, to be evaluated separately. +func splitExpression(value string) ([]eval, error) { + match := reVariable.FindStringSubmatch(value) + if len(match) == 0 { + if strings.Contains(value, "{{") || strings.Contains(value, "}}") { + return nil, trace.BadParameter( + "%q is using template brackets '{{' or '}}', however expression does not parse, make sure the format is {{expression}}", + value, + ) + } + return []eval{literal{text: strings.TrimSpace(value)}}, nil + } + + return []eval{ + literal{text: strings.TrimLeftFunc(match[1], unicode.IsSpace)}, + expression{text: match[2]}, + literal{text: strings.TrimRightFunc(match[3], unicode.IsSpace)}, + }, nil +} + +func evalTemplate(template string, spec *dbobjectv1.DatabaseObjectSpec) (string, error) { + chunks, err := splitExpression(template) + if err != nil { + return "", trace.Wrap(err) + } + + var sb strings.Builder + + for _, chunk := range chunks { + text, err := chunk.eval(spec) + if err != nil { + return "", trace.Wrap(err) + } + sb.WriteString(text) + } + + return sb.String(), nil +} + +func applyMappingToObject(mapping *dbobjectimportrulev1.DatabaseObjectImportRuleMapping, spec *dbobjectv1.DatabaseObjectSpec, labels map[string]string) (bool, error) { + // the matching is applied to the object spec; existing object labels does not matter + if !databaseObjectScopeMatch(mapping.GetScope(), spec) { + return false, nil + } + if !databaseObjectImportMatch(mapping.GetMatch(), spec) { + return false, nil + } + + for key, value := range mapping.AddLabels { + out, err := evalTemplate(value, spec) + if err != nil { + return false, trace.Wrap(err) + } + labels[key] = out + } + + return true, nil +} + +func matchPattern(pattern, value string) bool { + re, err := libutils.CompileExpression(pattern) + if err != nil { + return false + } + return re.MatchString(value) +} + +func matchAny(patterns []string, value string) bool { + return utils.Any(patterns, func(pattern string) bool { + return matchPattern(pattern, value) + }) +} + +func databaseObjectScopeMatch(scope *dbobjectimportrulev1.DatabaseObjectImportScope, spec *dbobjectv1.DatabaseObjectSpec) bool { + // require at least one match if there are any names to match against. + if len(scope.GetDatabaseNames()) > 0 && !matchAny(scope.GetDatabaseNames(), spec.GetDatabase()) { + return false + } + if len(scope.GetSchemaNames()) > 0 && !matchAny(scope.GetSchemaNames(), spec.GetSchema()) { + return false + } + return true +} + +func databaseObjectImportMatch(match *dbobjectimportrulev1.DatabaseObjectImportMatch, spec *dbobjectv1.DatabaseObjectSpec) bool { + switch spec.GetObjectKind() { + case ObjectKindTable: + return matchAny(match.GetTableNames(), spec.GetName()) + case ObjectKindView: + return matchAny(match.GetViewNames(), spec.GetName()) + case ObjectKindProcedure: + return matchAny(match.GetProcedureNames(), spec.GetName()) + default: + // unknown object kind + return false + } + +} + +const ( + ObjectKindTable = "table" + ObjectKindView = "view" + ObjectKindProcedure = "procedure" +) diff --git a/lib/srv/db/common/permissions/import_rules_test.go b/lib/srv/db/common/databaseobjectimportrule/apply_test.go similarity index 56% rename from lib/srv/db/common/permissions/import_rules_test.go rename to lib/srv/db/common/databaseobjectimportrule/apply_test.go index 4312eb3505bf0..77d4455902e9b 100644 --- a/lib/srv/db/common/permissions/import_rules_test.go +++ b/lib/srv/db/common/databaseobjectimportrule/apply_test.go @@ -14,19 +14,22 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package permissions +package databaseobjectimportrule import ( + "maps" "testing" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" + "github.com/gravitational/teleport/api/defaults" dbobjectv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobject/v1" databaseobjectimportrulev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobjectimportrule/v1" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/types/databaseobject" - "github.com/gravitational/teleport/api/types/databaseobjectimportrule" "github.com/gravitational/teleport/api/types/label" + "github.com/gravitational/teleport/lib/srv/db/common/databaseobject" ) func TestApplyDatabaseObjectImportRules(t *testing.T) { @@ -56,16 +59,31 @@ func TestApplyDatabaseObjectImportRules(t *testing.T) { } mkImportRule := func(name string, spec *databaseobjectimportrulev1.DatabaseObjectImportRuleSpec) *databaseobjectimportrulev1.DatabaseObjectImportRule { - out, err := databaseobjectimportrule.NewDatabaseObjectImportRule(name, spec) + out, err := NewDatabaseObjectImportRule(name, spec) require.NoError(t, err) return out } + + mkImportRuleNoValidation := func(name string, spec *databaseobjectimportrulev1.DatabaseObjectImportRuleSpec) *databaseobjectimportrulev1.DatabaseObjectImportRule { + out := &databaseobjectimportrulev1.DatabaseObjectImportRule{ + Kind: types.KindDatabaseObjectImportRule, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: name, + Namespace: defaults.Namespace, + }, + Spec: spec, + } + return out + } + tests := []struct { name string rules []*databaseobjectimportrulev1.DatabaseObjectImportRule database types.Database objs []*dbobjectv1.DatabaseObject want []*dbobjectv1.DatabaseObject + errCount int }{ { name: "empty inputs", @@ -173,15 +191,73 @@ func TestApplyDatabaseObjectImportRules(t *testing.T) { }), }, }, + { + name: "errors are counted", + rules: []*databaseobjectimportrulev1.DatabaseObjectImportRule{ + mkImportRule("foo", &databaseobjectimportrulev1.DatabaseObjectImportRuleSpec{ + Priority: 10, + DatabaseLabels: label.FromMap(map[string][]string{"*": {"*"}}), + Mappings: []*databaseobjectimportrulev1.DatabaseObjectImportRuleMapping{ + { + Match: &databaseobjectimportrulev1.DatabaseObjectImportMatch{ + TableNames: []string{"*"}, + }, + AddLabels: map[string]string{ + "dev_access": "rw", + "flag_from_dev": "dummy", + }, + }, + }, + }), + + mkImportRuleNoValidation("bar", &databaseobjectimportrulev1.DatabaseObjectImportRuleSpec{ + Priority: 20, + DatabaseLabels: label.FromMap(map[string][]string{"*": {"*"}}), + Mappings: []*databaseobjectimportrulev1.DatabaseObjectImportRuleMapping{ + { + Match: &databaseobjectimportrulev1.DatabaseObjectImportMatch{ + TableNames: []string{"*"}, + }, + AddLabels: map[string]string{ + "dev_access": "ro", + "flag_from_prod": "dummy", + }, + }, + { + Match: &databaseobjectimportrulev1.DatabaseObjectImportMatch{TableNames: []string{"bar", "baz"}}, + AddLabels: map[string]string{"error label": "{{foo()}}"}, + }, + }, + }), + }, + database: mkDatabase("dummy", map[string]string{}), + objs: []*dbobjectv1.DatabaseObject{ + mkDatabaseObject("foo", &dbobjectv1.DatabaseObjectSpec{ObjectKind: ObjectKindTable, Protocol: "postgres"}), + mkDatabaseObject("bar", &dbobjectv1.DatabaseObjectSpec{ObjectKind: ObjectKindTable, Protocol: "postgres"}), + mkDatabaseObject("baz", &dbobjectv1.DatabaseObjectSpec{ObjectKind: ObjectKindTable, Protocol: "postgres"}), + }, + want: []*dbobjectv1.DatabaseObject{ + mkDatabaseObject("foo", &dbobjectv1.DatabaseObjectSpec{ObjectKind: ObjectKindTable, Protocol: "postgres"}, func(db *dbobjectv1.DatabaseObject) error { + db.Metadata.Labels = map[string]string{ + "dev_access": "ro", + "flag_from_dev": "dummy", + "flag_from_prod": "dummy", + } + return nil + }), + }, + errCount: 2, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - out := ApplyDatabaseObjectImportRules(tt.rules, tt.database, tt.objs) + out, errCount := ApplyDatabaseObjectImportRules(logrus.StandardLogger(), tt.rules, tt.database, tt.objs) require.Len(t, out, len(tt.want)) for i, obj := range out { require.Equal(t, tt.want[i].String(), obj.String()) } + require.Equal(t, tt.errCount, errCount) }) } } @@ -404,3 +480,226 @@ func Test_databaseObjectScopeMatch(t *testing.T) { }) } } + +func Test_applyMappingToObject(t *testing.T) { + spec := &dbobjectv1.DatabaseObjectSpec{ + Database: "db3", + DatabaseServiceName: "service3", + Protocol: "postgres", + ObjectKind: ObjectKindTable, + Name: "object3", + Schema: "schema3", + } + + tests := []struct { + name string + mapping *databaseobjectimportrulev1.DatabaseObjectImportRuleMapping + labels map[string]string + wantLabels map[string]string + wantMatch bool + wantError bool + }{ + { + name: "simple templates", + mapping: &databaseobjectimportrulev1.DatabaseObjectImportRuleMapping{ + Match: &databaseobjectimportrulev1.DatabaseObjectImportMatch{ + TableNames: []string{"*"}, + }, + AddLabels: map[string]string{ + "plain_label": "rw", + "protocol": "{{obj.protocol}}", + "database_service_name": "{{obj.database_service_name}}", + "object_kind": "{{obj.object_kind}}", + "database": "{{obj.database}}", + "schema": "{{obj.schema}}", + "name": "{{obj.name}}", + }, + }, + labels: map[string]string{}, + wantLabels: map[string]string{ + "plain_label": "rw", + "protocol": "postgres", + "database_service_name": "service3", + "object_kind": "table", + "database": "db3", + "schema": "schema3", + "name": "object3", + }, + wantMatch: true, + }, + { + name: "add prefix", + mapping: &databaseobjectimportrulev1.DatabaseObjectImportRuleMapping{ + Match: &databaseobjectimportrulev1.DatabaseObjectImportMatch{ + TableNames: []string{"*"}, + }, + AddLabels: map[string]string{ + "plain_label": "rw", + "tag": "db-{{obj.object_kind}}", + }, + }, + labels: map[string]string{}, + wantLabels: map[string]string{ + "plain_label": "rw", + "tag": "db-table", + }, + wantMatch: true, + }, + { + name: "spaces are trimmed prefix", + mapping: &databaseobjectimportrulev1.DatabaseObjectImportRuleMapping{ + Match: &databaseobjectimportrulev1.DatabaseObjectImportMatch{ + TableNames: []string{"*"}, + }, + AddLabels: map[string]string{ + "plain_label": "rw", + "tag": " db-{{ obj.object_kind }}-bar ", + }, + }, + labels: map[string]string{}, + wantLabels: map[string]string{ + "plain_label": "rw", + "tag": "db-table-bar", + }, + wantMatch: true, + }, + { + name: "invalid object is rejected", + mapping: &databaseobjectimportrulev1.DatabaseObjectImportRuleMapping{ + Match: &databaseobjectimportrulev1.DatabaseObjectImportMatch{ + TableNames: []string{"*"}, + }, + AddLabels: map[string]string{ + "plain_label": "rw", + "tag": "db-{{obj.invalid}}", + }, + }, + labels: map[string]string{}, + wantError: true, + }, + { + name: "invalid namespace is rejected", + mapping: &databaseobjectimportrulev1.DatabaseObjectImportRuleMapping{ + Match: &databaseobjectimportrulev1.DatabaseObjectImportMatch{ + TableNames: []string{"*"}, + }, + AddLabels: map[string]string{ + "plain_label": "rw", + "tag": "db-{{wrong.object_kind}}", + }, + }, + labels: map[string]string{}, + wantError: true, + }, + { + name: "empty template is rejected", + mapping: &databaseobjectimportrulev1.DatabaseObjectImportRuleMapping{ + Match: &databaseobjectimportrulev1.DatabaseObjectImportMatch{ + TableNames: []string{"*"}, + }, + AddLabels: map[string]string{ + "plain_label": "rw", + "tag": "db-{{}}", + }, + }, + labels: map[string]string{}, + wantError: true, + }, + { + name: "multi template is rejected", + mapping: &databaseobjectimportrulev1.DatabaseObjectImportRuleMapping{ + Match: &databaseobjectimportrulev1.DatabaseObjectImportMatch{ + TableNames: []string{"*"}, + }, + AddLabels: map[string]string{ + "plain_label": "rw", + "tag": "db-{{obj.object_kind obj.object_kind}}", + }, + }, + labels: map[string]string{}, + wantError: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + labels := maps.Clone(tt.labels) + match, err := applyMappingToObject(tt.mapping, spec, labels) + if tt.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.wantMatch, match) + require.Equal(t, tt.wantLabels, labels) + } + }) + } +} + +func Test_splitExpression(t *testing.T) { + tests := []struct { + name string + value string + want []eval + wantErr bool + }{ + { + name: "empty string", + value: "", + want: []eval{literal{text: ""}}, + wantErr: false, + }, + { + name: "literal", + value: "literal", + want: []eval{literal{text: "literal"}}, + wantErr: false, + }, + { + name: "literal with whitespace", + value: " literal ", + want: []eval{literal{text: "literal"}}, + wantErr: false, + }, + { + name: "prefix, expr, suffix", + value: "prefix-{{expr}}-suffix", + want: []eval{literal{text: "prefix-"}, expression{text: "expr"}, literal{text: "-suffix"}}, + wantErr: false, + }, + { + name: "prefix, expr, suffix with extra whitespace", + value: " prefix-{{expr}}-suffix ", + want: []eval{literal{text: "prefix-"}, expression{text: "expr"}, literal{text: "-suffix"}}, + wantErr: false, + }, + { + name: "unmatched {{", + value: "foo bar {{ baz", + want: nil, + wantErr: true, + }, + { + name: "unmatched }}", + value: "foo bar }} baz", + want: nil, + wantErr: true, + }, + { + name: "multiple templates", + value: "foo {{bar}} {{baz}}", + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := splitExpression(tt.value) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, got) + } + }) + } +} diff --git a/api/types/databaseobjectimportrule/databaseobjectimportrule.go b/lib/srv/db/common/databaseobjectimportrule/create.go similarity index 88% rename from api/types/databaseobjectimportrule/databaseobjectimportrule.go rename to lib/srv/db/common/databaseobjectimportrule/create.go index 0949cb3652ec1..5dfb56595c671 100644 --- a/api/types/databaseobjectimportrule/databaseobjectimportrule.go +++ b/lib/srv/db/common/databaseobjectimportrule/create.go @@ -15,6 +15,8 @@ package databaseobjectimportrule import ( + "strings" + "github.com/gravitational/trace" "github.com/gravitational/teleport/api/defaults" @@ -71,5 +73,16 @@ func ValidateDatabaseObjectImportRule(rule *dbobjectimportrulev1.DatabaseObjectI if len(rule.Spec.Mappings) == 0 { return trace.BadParameter("missing mappings") } + for _, mapping := range rule.Spec.Mappings { + for key, template := range mapping.AddLabels { + if strings.TrimSpace(key) == "" { + return trace.BadParameter("invalid mapping: label name is empty or whitespace") + } + err := validateTemplate(template) + if err != nil { + return trace.Wrap(err, "mapping value failed to parse as template") + } + } + } return nil } diff --git a/api/types/databaseobjectimportrule/databaseobjectimportrule_test.go b/lib/srv/db/common/databaseobjectimportrule/create_test.go similarity index 78% rename from api/types/databaseobjectimportrule/databaseobjectimportrule_test.go rename to lib/srv/db/common/databaseobjectimportrule/create_test.go index bac708db80fb5..8253fbb7a0c4c 100644 --- a/api/types/databaseobjectimportrule/databaseobjectimportrule_test.go +++ b/lib/srv/db/common/databaseobjectimportrule/create_test.go @@ -162,6 +162,42 @@ func TestValidateDatabaseObjectImportRule(t *testing.T) { }, expectedError: trace.BadParameter("missing mappings"), }, + { + name: "invalid mapping key", + rule: &dbobjectimportrulev1.DatabaseObjectImportRule{ + Kind: types.KindDatabaseObjectImportRule, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "test", + Namespace: defaults.Namespace, + }, + Spec: &dbobjectimportrulev1.DatabaseObjectImportRuleSpec{ + DatabaseLabels: label.FromMap(map[string][]string{"key": {"value"}}), + Mappings: []*dbobjectimportrulev1.DatabaseObjectImportRuleMapping{{ + AddLabels: map[string]string{" ": "dummy"}, + }}, + }, + }, + expectedError: trace.BadParameter("invalid mapping: label name is empty or whitespace"), + }, + { + name: "invalid template in mapping", + rule: &dbobjectimportrulev1.DatabaseObjectImportRule{ + Kind: types.KindDatabaseObjectImportRule, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "test", + Namespace: defaults.Namespace, + }, + Spec: &dbobjectimportrulev1.DatabaseObjectImportRuleSpec{ + DatabaseLabels: label.FromMap(map[string][]string{"key": {"value"}}), + Mappings: []*dbobjectimportrulev1.DatabaseObjectImportRuleMapping{{ + AddLabels: map[string]string{"dummy": " {{ "}, + }}, + }, + }, + expectedError: trace.Wrap(trace.BadParameter("\" {{ \" is using template brackets '{{' or '}}', however expression does not parse, make sure the format is {{expression}}"), "mapping value failed to parse as template"), + }, } for _, tt := range tests { diff --git a/lib/srv/db/common/permissions/presets.go b/lib/srv/db/common/databaseobjectimportrule/preset.go similarity index 68% rename from lib/srv/db/common/permissions/presets.go rename to lib/srv/db/common/databaseobjectimportrule/preset.go index 21389479d8ff4..d627e23d0f954 100644 --- a/lib/srv/db/common/permissions/presets.go +++ b/lib/srv/db/common/databaseobjectimportrule/preset.go @@ -14,34 +14,36 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package permissions +package databaseobjectimportrule import ( log "github.com/sirupsen/logrus" dbobjectimportrulev1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobjectimportrule/v1" - "github.com/gravitational/teleport/api/types/databaseobjectimportrule" "github.com/gravitational/teleport/api/types/label" ) // NewPresetImportAllObjectsRule creates new "import_all_objects" database object import rule, which applies `kind: ` label to all database objects. // This is a convenience rule and users are free to modify it to suit their needs. func NewPresetImportAllObjectsRule() *dbobjectimportrulev1pb.DatabaseObjectImportRule { - rule, err := databaseobjectimportrule.NewDatabaseObjectImportRule("import_all_objects", &dbobjectimportrulev1pb.DatabaseObjectImportRuleSpec{ + rule, err := NewDatabaseObjectImportRule("import_all_objects", &dbobjectimportrulev1pb.DatabaseObjectImportRuleSpec{ Priority: 0, DatabaseLabels: label.FromMap(map[string][]string{"*": {"*"}}), Mappings: []*dbobjectimportrulev1pb.DatabaseObjectImportRuleMapping{ { - Match: &dbobjectimportrulev1pb.DatabaseObjectImportMatch{TableNames: []string{"*"}}, - AddLabels: map[string]string{"kind": ObjectKindTable}, - }, - { - Match: &dbobjectimportrulev1pb.DatabaseObjectImportMatch{ViewNames: []string{"*"}}, - AddLabels: map[string]string{"kind": ObjectKindView}, - }, - { - Match: &dbobjectimportrulev1pb.DatabaseObjectImportMatch{ProcedureNames: []string{"*"}}, - AddLabels: map[string]string{"kind": ObjectKindProcedure}, + Match: &dbobjectimportrulev1pb.DatabaseObjectImportMatch{ + TableNames: []string{"*"}, + ViewNames: []string{"*"}, + ProcedureNames: []string{"*"}, + }, + AddLabels: map[string]string{ + "protocol": "{{obj.protocol}}", + "database_service_name": "{{obj.database_service_name}}", + "object_kind": "{{obj.object_kind}}", + "database": "{{obj.database}}", + "schema": "{{obj.schema}}", + "name": "{{obj.name}}", + }, }, }, }) diff --git a/lib/srv/db/common/permissions/presets_test.go b/lib/srv/db/common/databaseobjectimportrule/preset_test.go similarity index 96% rename from lib/srv/db/common/permissions/presets_test.go rename to lib/srv/db/common/databaseobjectimportrule/preset_test.go index a26de068befd4..3316fb434cfd1 100644 --- a/lib/srv/db/common/permissions/presets_test.go +++ b/lib/srv/db/common/databaseobjectimportrule/preset_test.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package permissions +package databaseobjectimportrule import ( "testing" diff --git a/lib/srv/db/common/permissions/calculation_test.go b/lib/srv/db/common/permissions/calculation_test.go index c33895af52d10..fe88f6c8495a8 100644 --- a/lib/srv/db/common/permissions/calculation_test.go +++ b/lib/srv/db/common/permissions/calculation_test.go @@ -24,9 +24,10 @@ import ( dbobjectv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobject/v1" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/types/databaseobject" "github.com/gravitational/teleport/api/types/events" "github.com/gravitational/teleport/api/utils" + "github.com/gravitational/teleport/lib/srv/db/common/databaseobject" + "github.com/gravitational/teleport/lib/srv/db/common/databaseobjectimportrule" ) type mockGetter struct { @@ -43,7 +44,7 @@ func TestCalculatePermissions(t *testing.T) { out, err := databaseobject.NewDatabaseObjectWithLabels(name, maps.Clone(labels), &dbobjectv1.DatabaseObjectSpec{ Protocol: types.DatabaseProtocolPostgreSQL, DatabaseServiceName: "dummy", - ObjectKind: ObjectKindTable, + ObjectKind: databaseobjectimportrule.ObjectKindTable, Database: "dummy", Schema: "public", Name: name, @@ -176,7 +177,7 @@ func TestDatabasePermissionMatch(t *testing.T) { out, err := databaseobject.NewDatabaseObjectWithLabels("foo", maps.Clone(labels), &dbobjectv1.DatabaseObjectSpec{ Protocol: types.DatabaseProtocolPostgreSQL, DatabaseServiceName: "dummy", - ObjectKind: ObjectKindTable, + ObjectKind: databaseobjectimportrule.ObjectKindTable, Database: "dummy", Schema: "public", Name: "foo", diff --git a/lib/srv/db/common/permissions/import_rules.go b/lib/srv/db/common/permissions/import_rules.go deleted file mode 100644 index f6fef0ff24839..0000000000000 --- a/lib/srv/db/common/permissions/import_rules.go +++ /dev/null @@ -1,141 +0,0 @@ -// Teleport -// Copyright (C) 2023 Gravitational, Inc. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package permissions - -import ( - "sort" - - dbobjectv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobject/v1" - dbobjectimportrulev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobjectimportrule/v1" - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/types/label" - "github.com/gravitational/teleport/api/utils" - "github.com/gravitational/teleport/lib/services" - libutils "github.com/gravitational/teleport/lib/utils" -) - -// ApplyDatabaseObjectImportRules applies the given set of rules onto a set of objects coming from a same database. -// Returns a fresh copy of a subset of supplied objects, filtered and modified. -// For the object to be returned, it must match at least one rule. -// The modification consists of application of extra labels, per matching mappings. -func ApplyDatabaseObjectImportRules(rules []*dbobjectimportrulev1.DatabaseObjectImportRule, database types.Database, objs []*dbobjectv1.DatabaseObject) []*dbobjectv1.DatabaseObject { - // sort: rules with higher priorities are applied last. - sort.Slice(rules, func(i, j int) bool { - return rules[i].Spec.Priority < rules[j].Spec.Priority - }) - - // filter rules: keep those with matching labels - // we only need mappings from the rules, so extract those. - var mappings []*dbobjectimportrulev1.DatabaseObjectImportRuleMapping - for _, rule := range rules { - dbLabels := make(types.Labels) - mapLabel := label.ToMap(rule.Spec.GetDatabaseLabels()) - for k, v := range mapLabel { - dbLabels[k] = v - } - if ok, _, _ := services.MatchLabels(dbLabels, database.GetAllLabels()); ok { - mappings = append(mappings, rule.Spec.Mappings...) - } - } - - // anything to do? - if len(mappings) == 0 { - return nil - } - - var out []*dbobjectv1.DatabaseObject - - // find all objects that match any of the rules - for _, obj := range objs { - var objClone *dbobjectv1.DatabaseObject - - // apply each mapping in order. - for _, mapping := range mappings { - // the matching is applied to the object spec; existing object labels does not matter - if !databaseObjectScopeMatch(mapping.GetScope(), obj.GetSpec()) { - continue - } - if databaseObjectImportMatch(mapping.GetMatch(), obj.GetSpec()) { - if objClone == nil { - objClone = utils.CloneProtoMsg(obj) - } - - // mapping applies additional labels - labels := objClone.Metadata.Labels - if labels == nil { - labels = map[string]string{} - } - for k, v := range mapping.AddLabels { - labels[k] = v - } - objClone.Metadata.Labels = labels - } - } - - if objClone != nil { - out = append(out, objClone) - } - } - - return out -} - -func matchPattern(pattern, value string) bool { - re, err := libutils.CompileExpression(pattern) - if err != nil { - return false - } - return re.MatchString(value) -} - -func matchAny(patterns []string, value string) bool { - return utils.Any(patterns, func(pattern string) bool { - return matchPattern(pattern, value) - }) -} - -func databaseObjectScopeMatch(scope *dbobjectimportrulev1.DatabaseObjectImportScope, spec *dbobjectv1.DatabaseObjectSpec) bool { - // require at least one match if there are any names to match against. - if len(scope.GetDatabaseNames()) > 0 && !matchAny(scope.GetDatabaseNames(), spec.GetDatabase()) { - return false - } - if len(scope.GetSchemaNames()) > 0 && !matchAny(scope.GetSchemaNames(), spec.GetSchema()) { - return false - } - return true -} - -func databaseObjectImportMatch(match *dbobjectimportrulev1.DatabaseObjectImportMatch, spec *dbobjectv1.DatabaseObjectSpec) bool { - switch spec.GetObjectKind() { - case ObjectKindTable: - return matchAny(match.GetTableNames(), spec.GetName()) - case ObjectKindView: - return matchAny(match.GetViewNames(), spec.GetName()) - case ObjectKindProcedure: - return matchAny(match.GetProcedureNames(), spec.GetName()) - default: - // unknown object kind - return false - } - -} - -const ( - ObjectKindTable = "table" - ObjectKindView = "view" - ObjectKindProcedure = "procedure" -) diff --git a/lib/srv/db/postgres/schema.go b/lib/srv/db/postgres/schema.go index 072c5c66b0a8a..4712d764f923a 100644 --- a/lib/srv/db/postgres/schema.go +++ b/lib/srv/db/postgres/schema.go @@ -22,9 +22,9 @@ import ( "github.com/jackc/pgx/v4" dbobjectv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobject/v1" - "github.com/gravitational/teleport/api/types/databaseobject" "github.com/gravitational/teleport/lib/srv/db/common" - "github.com/gravitational/teleport/lib/srv/db/common/permissions" + "github.com/gravitational/teleport/lib/srv/db/common/databaseobject" + "github.com/gravitational/teleport/lib/srv/db/common/databaseobjectimportrule" ) // schemaInfo represents information about all schemas in a database. @@ -54,14 +54,14 @@ func fetchDatabaseObjects(ctx context.Context, session *common.Session, conn *pg session.Database.GetProtocol(), session.Database.GetType(), session.Database.GetName(), - permissions.ObjectKindTable, + databaseobjectimportrule.ObjectKindTable, session.DatabaseName, schemaName, table, }, "/") obj, err := databaseobject.NewDatabaseObject(name, &dbobjectv1.DatabaseObjectSpec{ - ObjectKind: permissions.ObjectKindTable, + ObjectKind: databaseobjectimportrule.ObjectKindTable, DatabaseServiceName: session.Database.GetName(), Protocol: session.Database.GetProtocol(), Database: session.DatabaseName, diff --git a/lib/srv/db/postgres/users.go b/lib/srv/db/postgres/users.go index 832f2bbcdf3eb..67717fd819c7e 100644 --- a/lib/srv/db/postgres/users.go +++ b/lib/srv/db/postgres/users.go @@ -33,6 +33,7 @@ import ( "github.com/gravitational/teleport/api/types" apiawsutils "github.com/gravitational/teleport/api/utils/aws" "github.com/gravitational/teleport/lib/srv/db/common" + "github.com/gravitational/teleport/lib/srv/db/common/databaseobjectimportrule" "github.com/gravitational/teleport/lib/srv/db/common/permissions" ) @@ -125,7 +126,7 @@ var pgTablePerms = map[string]struct{}{ func checkPgPermission(objKind, perm string) error { // for now, only tables are supported. ignore other kinds of objects. - if objKind != permissions.ObjectKindTable { + if objKind != databaseobjectimportrule.ObjectKindTable { return nil } @@ -147,7 +148,7 @@ func convertPermissions(perms permissions.PermissionSet) (*Permissions, error) { errors = append(errors, err) continue } - if obj.GetSpec().ObjectKind == permissions.ObjectKindTable { + if obj.GetSpec().ObjectKind == databaseobjectimportrule.ObjectKindTable { out.Tables = append(out.Tables, TablePermission{ Privilege: permission, Schema: obj.GetSpec().Schema, @@ -207,14 +208,14 @@ func (e *Engine) applyPermissions(ctx context.Context, sessionCtx *common.Sessio if err != nil { return trace.Wrap(err) } - counts, countMap := permissions.CountObjectKinds(objsFetched) - e.Log.WithField("kind_counts", countMap).WithField("total", len(objsFetched)).Infof("Fetched %v objects from the database (%v).", len(objsFetched), counts) + counts, _ := permissions.CountObjectKinds(objsFetched) + e.Log.WithField("total", len(objsFetched)).Infof("Database objects fetched from the database (%v).", counts) - objsTagged := permissions.ApplyDatabaseObjectImportRules(rules, sessionCtx.Database, objsFetched) - counts, countMap = permissions.CountObjectKinds(objsTagged) - e.Log.WithField("kind_counts", countMap).WithField("total", len(objsFetched)).Infof("Tagged %v database objects (%v).", len(objsTagged), counts) + objsImported, errCount := databaseobjectimportrule.ApplyDatabaseObjectImportRules(e.Log, rules, sessionCtx.Database, objsFetched) + counts, _ = permissions.CountObjectKinds(objsImported) + e.Log.WithField("err_count", errCount).WithField("total", len(objsFetched)).Infof("Database objects imported (%v).", counts) - permissionSet, err := permissions.CalculatePermissions(sessionCtx.Checker, sessionCtx.Database, objsTagged) + permissionSet, err := permissions.CalculatePermissions(sessionCtx.Checker, sessionCtx.Database, objsImported) if err != nil { return trace.Wrap(err) } diff --git a/lib/srv/db/postgres/users_test.go b/lib/srv/db/postgres/users_test.go index f7bc2396cb2e5..a9e2bf00d3b34 100644 --- a/lib/srv/db/postgres/users_test.go +++ b/lib/srv/db/postgres/users_test.go @@ -27,9 +27,10 @@ import ( dbobjectv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobject/v1" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/types/databaseobject" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/srv/db/common" + "github.com/gravitational/teleport/lib/srv/db/common/databaseobject" + "github.com/gravitational/teleport/lib/srv/db/common/databaseobjectimportrule" "github.com/gravitational/teleport/lib/srv/db/common/permissions" ) @@ -100,25 +101,25 @@ func TestCheckPgPermission(t *testing.T) { { name: "valid permission", perm: "SELECT", - objKind: permissions.ObjectKindTable, + objKind: databaseobjectimportrule.ObjectKindTable, checkErr: require.NoError, }, { name: "whitespace trimmed", perm: " SELECT ", - objKind: permissions.ObjectKindTable, + objKind: databaseobjectimportrule.ObjectKindTable, checkErr: require.NoError, }, { name: "case-insensitive", perm: "seLEct", - objKind: permissions.ObjectKindTable, + objKind: databaseobjectimportrule.ObjectKindTable, checkErr: require.NoError, }, { name: "invalid permission", perm: "INVALID", - objKind: permissions.ObjectKindTable, + objKind: databaseobjectimportrule.ObjectKindTable, checkErr: func(t require.TestingT, err error, i ...interface{}) { require.ErrorContains(t, err, "unrecognized \"table\" Postgres permission: \"INVALID\"") }, @@ -126,7 +127,7 @@ func TestCheckPgPermission(t *testing.T) { { name: "multiple permissions not allowed", perm: "SELECT, UPDATE", - objKind: permissions.ObjectKindTable, + objKind: databaseobjectimportrule.ObjectKindTable, checkErr: func(t require.TestingT, err error, i ...interface{}) { require.ErrorContains(t, err, "unrecognized \"table\" Postgres permission: \"SELECT, UPDATE\"") }, @@ -168,9 +169,9 @@ func TestConvertPermissions(t *testing.T) { { name: "valid table permissions, ignoring procedure", input: permissions.PermissionSet{ - "SELECT": {mkObject("my_table", "public", permissions.ObjectKindTable)}, - "INSERT": {mkObject("other_table", "secret", permissions.ObjectKindTable)}, - "EXECUTE": {mkObject("my_proc", "public", permissions.ObjectKindProcedure)}, + "SELECT": {mkObject("my_table", "public", databaseobjectimportrule.ObjectKindTable)}, + "INSERT": {mkObject("other_table", "secret", databaseobjectimportrule.ObjectKindTable)}, + "EXECUTE": {mkObject("my_proc", "public", databaseobjectimportrule.ObjectKindProcedure)}, }, expected: &Permissions{ Tables: []TablePermission{ @@ -190,7 +191,7 @@ func TestConvertPermissions(t *testing.T) { { name: "invalid table permissions lead to an error", input: permissions.PermissionSet{ - "invalid": {mkObject("my_table", "public", permissions.ObjectKindTable)}, + "invalid": {mkObject("my_table", "public", databaseobjectimportrule.ObjectKindTable)}, }, expectedError: trace.BadParameter("unrecognized \"table\" Postgres permission: \"invalid\""), }, diff --git a/rfd/0151-database-permission-management.md b/rfd/0151-database-permission-management.md new file mode 100644 index 0000000000000..c6e845f9b2794 --- /dev/null +++ b/rfd/0151-database-permission-management.md @@ -0,0 +1,461 @@ +--- +authors: Krzysztof Skrzętnicki (krzysztof.skrzetnicki@goteleport.com) +state: implemented +--- + +# RFD 151 - Database Permission Management + +## Required Approvals + +- Engineering: @r0mant && @greedy52 +- Product: @xinding33 || @klizhentas +- Security: @reedloden || @jentfoo + +## What + +Extends [automated db user provisioning](0113-automatic-database-users.md) with +permission management capabilities. + +## Why + +Database-level permission management is a natural extension of current Teleport +RBAC capabilities. The described model should integrate seamlessly with TAG, +providing a future-proof solution. + +Database administrators will be able to use Teleport for both user and +permission management. + +## Details + +### Database object import rules + +Teleport will fetch database objects and store them as resources in the backend, +using a set of global "database object import rules". At certain points in time, +the database schema will be read and passed through the import rules. Each +import rule defines a set of database labels that must match in order for the +import rule to fire. The rules are processed in order (as defined by the +`priority` field). + +Each import rule defines a set mappings. Individual mapping consists of a scope +definition (`database_names`, `schema_names`), object names to match +(`procedure_names`, `table_names`, ...) and a list of labels to apply. The +expected values for object names are specified as a list, while the matching +uses glob-like semantics, with `*` matching any set of characters. Empty list of +names matches nothing, while an empty scope matches everything. + +The labels applied by the import rules can reference the object properties, like +`schema` or `database`, to populate the labels with dynamic, object-dependent +values. + +#### Examples + +The `import_all_objects` below is a catch-all import rule which tags all objects +in all databases with values taken from database object spec. This also a +default import rule that will be created on startup if there are no other rules +present. + +```yaml +kind: db_object_import_rule +metadata: + name: import_all_objects + namespace: default +spec: + database_labels: + - name: '*' + values: + - '*' + mappings: + - add_labels: + database: '{{obj.database}}' + kind: '{{obj.object_kind}}' + name: '{{obj.name}}' + protocol: '{{obj.protocol}}' + schema: '{{obj.schema}}' + service_name: '{{obj.database_service_name}}' + match: + procedure_names: + - '*' + table_names: + - '*' + view_names: + - '*' +version: v1 +``` + +As another example, the `widget-prod` import rule applies to databases with +`env: prod` label. It has the priority 10. If there is a database object that +matches the clauses in `spec.mappings.match`, the import rule will apply the +specified labels to the database object (`env: prod`, +`product: WidgetMaster3000`). The `local_id` label references multiple values, +which will be concatenated to produce a single string. + +```yaml +kind: db_object_import_rule +version: v1 +metadata: + name: rule_widget_prod +spec: + priority: 10 + database_labels: + env: prod + mappings: + - scope: + database_names: + - Widget* + schema_names: + - widget + - sales + - public + - secret + match: + procedure_names: + - '*sales*' + table_names: + - '*sales*' + view_names: + - '*sales*' + add_labels: + env: prod + product: WidgetMaster3000 + schema_with_prefix: 'schema-{{obj.schema}}' +``` + +There is a Postgres database with a matching labels. Parsing the schema, a +number of objects is found, including the following one which matches the import +rule above (pattern matches are noted): + +```yaml +kind: db_object +version: v1 +metadata: + name: widget-sales +spec: + database: WidgetUltimate # matches 'Widget*' + db_service_name: all-things-widget + name: widget-sales # matches '*sales*' + object_kind: table + protocol: postgres + schema: sales +``` + +After processing, the import rule has applied the labels to the object. As there +are no other import rules matching this object, it is stored in the backend in +this form. + +```yaml +kind: db_object +version: v1 +metadata: + name: widget-sales + labels: + env: prod + product: WidgetMaster3000 +spec: + database: WidgetUltimate + db_service_name: all-things-widget + name: widget-sales + object_kind: table + protocol: postgres + schema: sales +``` + +Any particular attribute can be omitted from the import rule; an import rule +with empty `match` part will match all objects. + +A single rule can also specify multiple sets of labels to be applied. This is +useful for applying a label with different set of values, depending on database +object attributes. + +```yaml +kind: db_object_import_rule +version: v1 +metadata: + name: mark_confidential +spec: + database_labels: + env: prod + mappings: + - scope: + schema_names: + - private + - sales + - secret + add_labels: + confidential: 'true' + - scope: + schema_names: + - public + add_labels: + confidential: 'false' + priority: 20 +``` + +As another example, a wide rule to import all tables in schema "public" from all +staging databases may look as follows: + +```yaml +kind: db_object_import_rule +version: v1 +metadata: + name: import_all_staging_tables +spec: + database_labels: + env: staging + mappings: + - add_labels: + custom_label: my_custom_value + env: staging + match: + table_names: + - '*' + scope: + schema_names: + - public + priority: 30 +``` + +A more fine-grained rule, targeting a specific set of tables: + +```yaml +kind: db_object_import_rule +version: v1 +metadata: + name: import_specific_tables +spec: + database_labels: + env: dev + mappings: + - add_labels: + custom_label: my_custom_value + env: dev + match: + table_names: + - table1 + - table2 + - table3 + scope: + schema_names: + - public + priority: 30 +``` + +#### Import process + +The database objects are imported by the database agent when establishing a new +user session to the database. In this context, they are immediate inputs for +permission calculation; the desired per-object permissions are subsequently +written back to the database. + +Imported objects are also stored in the backend, where TAG can access them. This +enables the TAG to visualise the permissions. + +Additionally, the imports will be done on a predetermined schedule (e.g. every +10 minutes), and stored in the backend. If the database engine supports it, the +sync may also happen when a schema change is detected. For example, in Postgres +we case use +[trigger+notify](https://medium.com/launchpad-lab/postgres-triggers-with-listen-notify-565b44ccd782). + +#### Import result: the `db_object` resource + +Import process creates a number of `db_object` resources with applied labels. +Aside from standard metadata fields, the object spec consists of a number of +predefined attributes: + +```protobuf +// DatabaseObjectSpec is the spec for the database object. +message DatabaseObjectSpec { + string protocol = 1; + string database_service_name = 2; + string object_kind = 3; + string database = 4; + string schema = 5; + string name = 6; +} +``` + +All of the above fields are optional, and more may be added in the future if +needed. The database-specific implementation is responsible for populating these +fields from the database schema. + +### Database object permissions + +The permissions for particular objects are defined in a role using the new +`db_permissions` field, found under `spec.allow` and `spec.deny` respectively. +Each permission specifies: + +- A list of labels the database object must match. +- A list of permissions that will be given to the object for the user. + +As an example, the role `db_read_non_confidential` allows access to tables and +views with a label `confidential: false` and explicitly disallows any access to +those labeled `confidential: true`. + +```yaml +kind: role +version: v7 +metadata: + name: db_read_non_confidential +spec: + allow: + db_permissions: + - match: + confidential: 'false' + kind: + - table + - view + permissions: + - SELECT + - INSERT + - UPDATE + deny: + db_permissions: + - match: + confidential: 'true' + permissions: + - '*' +``` + +The object attributes are not replicated automatically as labels. In the example +above, `kind` is a label which must be applied by an import rule, for example +one as follows: + +```yaml +kind: db_object_import_rule +metadata: + name: object-kind +spec: + database_labels: + '*': '*' + mappings: + - add_labels: + kind: table + match: + table_names: + - '*' + - add_labels: + kind: view + match: + view_names: + - '*' + - add_labels: + kind: procedure + match: + procedure_names: + - '*' + priority: 100 +version: v1 +``` + +#### Applying permissions + +The permissions will be applied to the user after the user is provisioned in the +database. The exact mechanism will be database-specific; for example, in SQL +databases like Postgres or MySQL this will be done through appropriate `GRANT` +statements, executed through a helper stored procedure. + +After the session is finished, the user is removed/deactivated, and _all_ +permissions must be revoked. Again, for SQL databases, a corresponding `REVOKE` +statements will be issued. To ensure complete removal of all permissions, the +stored procedure will iterate over all schemas and objects within, revoking the +access to each individual object. + +Updating the permissions due to schema change is a feature that is out of scope +of initial implementation. + +Similarly, the permissions will not be updated due to changed role definition. +Finally, the list of roles for a given user is unchanging in the scope of a +single connection, so this is not an element that may change. + +To avoid confusion regarding the source of access, `db_permissions` will be +mutually exclusive with `db_roles`. + +The precise meaning of individual permissions is database-specific. + +However, we mandate the interaction between the `deny` and `allow` parts: + +- permissions found in `deny` remove matching permissions in `allow`; +- the permissions are compared as strings in case-insensitive manner, with + trimmed whitespace; +- `*` in `deny` matches all permissions in `allow`. + +For example, if the `allow` permission contains `SELECT`, then it can be removed +with `select`, `SELECT` or `*`. + +Invalid permissions are prohibited. This is a fatal error and should cause +connection error. + +### TAG Integration + +The `db_object` resources can be imported to TAG, which can reimplement the +permission semantics described above. The permission algorithm is based on label +matching, which should be an operation that is easy to support in TAG, given its +widespread usage in Teleport RBAC. Performance wise it may be necessary to avoid +queries spanning objects from multiple databases - a single database can viably +produce thousands of objects - but nevertheless, it should remain a scalable +approach. + +### Backward Compatibility + +The new `db_permissions` field should be automatically ignored by Teleport +versions that don't support it. The new resources will likewise be ignored. + +### Audit Events + +The [RFD 113](0113-automatic-database-users.md) introduces events +`db.user.created` and `db.user.disabled`, but these are yet to be implemented. + +The `db.user.created` should be extended with a summarized list of applied +permissions (permission types, object types, counts). + +### Observability + +The expectation is that permission should be applied swiftly. If necessary, we +may consider monitoring the latency of schema queries and the time required to +apply permissions. This becomes particularly relevant when permissions are +managed in the systems external to the database instance, for instance through a +call to AWS Security Token Service (STS). + +To enhance observability, appropriate logging will be added in key points. +Debug-level logs can be used to detail individual permissions granted, while +keeping in mind that the full list might be thousands of entries long. + +### Product Usage + +A new PostHog event should be added, summarizing the information from the +`db.user.created` audit event: protocol, number of affected objects of each +kind, number of permissions. + +### Test Plan + +The feature shall be tested using automated e2e tests. Each supported +configuration should be tested separately. + +### Security + +The introduction of the new feature has no direct impact on the security of +Teleport itself, as the permissions will be applied to resourced managed with +Teleport, not any of the internal services. However, it does have implications +for the security of connected databases. As a hardening measure, a blanked deny +role can be used to deny all possible permissions: + +```yaml +kind: role +version: v7 +metadata: + name: db_deny_all_permissions +spec: + deny: + db_permissions: + - match: + '*': '*' + permissions: + - '*' +``` + +It's important to recognize that with sufficiently broad permissions, a user +might have the potential to elevate their database permissions further via +database-specific means, including creating additional users and granting +permissions. It is responsibility of the system administrator to ensure the +permissions are not excessive, and this fact should be reflected in Teleport +documentation for this feature. However, a suitable IGS report may be helpful in +this context. diff --git a/tool/tctl/common/collection_test.go b/tool/tctl/common/collection_test.go index 2b8d33629467e..08fcdd3a97a14 100644 --- a/tool/tctl/common/collection_test.go +++ b/tool/tctl/common/collection_test.go @@ -31,9 +31,9 @@ import ( "github.com/gravitational/teleport/api" dbobjectimportrulev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobjectimportrule/v1" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/types/databaseobjectimportrule" "github.com/gravitational/teleport/api/types/label" "github.com/gravitational/teleport/lib/asciitable" + "github.com/gravitational/teleport/lib/srv/db/common/databaseobjectimportrule" "github.com/gravitational/teleport/tool/common" )