diff --git a/pkg/apis/identity/v0alpha1/register.go b/pkg/apis/identity/v0alpha1/register.go index 48da15e9797..68c82f71969 100644 --- a/pkg/apis/identity/v0alpha1/register.go +++ b/pkg/apis/identity/v0alpha1/register.go @@ -121,6 +121,30 @@ var SSOSettingResourceInfo = common.NewResourceInfo( }, ) +var TeamBindingResourceInfo = common.NewResourceInfo( + GROUP, VERSION, "teambindings", "teambinding", "TeamBinding", + func() runtime.Object { return &TeamBinding{} }, + func() runtime.Object { return &TeamBindingList{} }, + utils.TableColumns{ + Definition: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Team", Type: "string"}, + {Name: "Created At", Type: "string", Format: "date"}, + }, + Reader: func(obj any) ([]interface{}, error) { + m, ok := obj.(*TeamBinding) + if !ok { + return nil, fmt.Errorf("expected team binding") + } + return []interface{}{ + m.Name, + m.Spec.TeamRef.Name, + m.CreationTimestamp.UTC().Format(time.RFC3339), + }, nil + }, + }, +) + var ( // SchemeGroupVersion is group version used to register these objects SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} @@ -144,6 +168,8 @@ func AddKnownTypes(scheme *runtime.Scheme, version string) { &IdentityDisplayResults{}, &SSOSetting{}, &SSOSettingList{}, + &TeamBinding{}, + &TeamBindingList{}, ) } diff --git a/pkg/apis/identity/v0alpha1/types_team.go b/pkg/apis/identity/v0alpha1/types_team.go index 7b4ecba072c..3a70bd8c36f 100644 --- a/pkg/apis/identity/v0alpha1/types_team.go +++ b/pkg/apis/identity/v0alpha1/types_team.go @@ -1,6 +1,8 @@ package v0alpha1 -import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type Team struct { @@ -9,6 +11,7 @@ type Team struct { Spec TeamSpec `json:"spec,omitempty"` } + type TeamSpec struct { Title string `json:"name,omitempty"` Email string `json:"email,omitempty"` @@ -21,3 +24,47 @@ type TeamList struct { Items []Team `json:"items,omitempty"` } + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type TeamBinding struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec TeamBindingSpec `json:"spec,omitempty"` +} + +type TeamBindingSpec struct { + Subjects []TeamSubject `json:"subjects,omitempty"` + TeamRef TeamRef `json:"teamRef,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type TeamBindingList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []TeamBinding `json:"items,omitempty"` +} + +type TeamSubject struct { + // Name is the unique identifier for subject. + Name string `json:"name,omitempty"` + + // Permission subject has in permission. + // Can be either admin or member. + Permission TeamPermission `json:"permission,omitempty"` +} + +type TeamRef struct { + // Name is the unique identifier for a team. + Name string `json:"name,omitempty"` +} + +// TeamPermission for subject +// +enum +type TeamPermission string + +const ( + TeamPermissionAdmin TeamPermission = "admin" + TeamPermissionMember TeamPermission = "member" +) diff --git a/pkg/apis/identity/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/identity/v0alpha1/zz_generated.deepcopy.go index 652138bcec2..1e74f720214 100644 --- a/pkg/apis/identity/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/identity/v0alpha1/zz_generated.deepcopy.go @@ -247,6 +247,88 @@ func (in *Team) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TeamBinding) DeepCopyInto(out *TeamBinding) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TeamBinding. +func (in *TeamBinding) DeepCopy() *TeamBinding { + if in == nil { + return nil + } + out := new(TeamBinding) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TeamBinding) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TeamBindingList) DeepCopyInto(out *TeamBindingList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]TeamBinding, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TeamBindingList. +func (in *TeamBindingList) DeepCopy() *TeamBindingList { + if in == nil { + return nil + } + out := new(TeamBindingList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TeamBindingList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TeamBindingSpec) DeepCopyInto(out *TeamBindingSpec) { + *out = *in + if in.Subjects != nil { + in, out := &in.Subjects, &out.Subjects + *out = make([]TeamSubject, len(*in)) + copy(*out, *in) + } + out.TeamRef = in.TeamRef + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TeamBindingSpec. +func (in *TeamBindingSpec) DeepCopy() *TeamBindingSpec { + if in == nil { + return nil + } + out := new(TeamBindingSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TeamList) DeepCopyInto(out *TeamList) { *out = *in @@ -280,6 +362,22 @@ func (in *TeamList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TeamRef) DeepCopyInto(out *TeamRef) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TeamRef. +func (in *TeamRef) DeepCopy() *TeamRef { + if in == nil { + return nil + } + out := new(TeamRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TeamSpec) DeepCopyInto(out *TeamSpec) { *out = *in @@ -296,6 +394,22 @@ func (in *TeamSpec) DeepCopy() *TeamSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TeamSubject) DeepCopyInto(out *TeamSubject) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TeamSubject. +func (in *TeamSubject) DeepCopy() *TeamSubject { + if in == nil { + return nil + } + out := new(TeamSubject) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *User) DeepCopyInto(out *User) { *out = *in diff --git a/pkg/apis/identity/v0alpha1/zz_generated.openapi.go b/pkg/apis/identity/v0alpha1/zz_generated.openapi.go index 9ba88920d25..0c71545ef9b 100644 --- a/pkg/apis/identity/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/identity/v0alpha1/zz_generated.openapi.go @@ -23,8 +23,13 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.ServiceAccountList": schema_pkg_apis_identity_v0alpha1_ServiceAccountList(ref), "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.ServiceAccountSpec": schema_pkg_apis_identity_v0alpha1_ServiceAccountSpec(ref), "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.Team": schema_pkg_apis_identity_v0alpha1_Team(ref), + "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamBinding": schema_pkg_apis_identity_v0alpha1_TeamBinding(ref), + "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamBindingList": schema_pkg_apis_identity_v0alpha1_TeamBindingList(ref), + "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamBindingSpec": schema_pkg_apis_identity_v0alpha1_TeamBindingSpec(ref), "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamList": schema_pkg_apis_identity_v0alpha1_TeamList(ref), + "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamRef": schema_pkg_apis_identity_v0alpha1_TeamRef(ref), "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamSpec": schema_pkg_apis_identity_v0alpha1_TeamSpec(ref), + "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamSubject": schema_pkg_apis_identity_v0alpha1_TeamSubject(ref), "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.User": schema_pkg_apis_identity_v0alpha1_User(ref), "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.UserList": schema_pkg_apis_identity_v0alpha1_UserList(ref), "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.UserSpec": schema_pkg_apis_identity_v0alpha1_UserSpec(ref), @@ -449,6 +454,126 @@ func schema_pkg_apis_identity_v0alpha1_Team(ref common.ReferenceCallback) common } } +func schema_pkg_apis_identity_v0alpha1_TeamBinding(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamBindingSpec"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamBindingSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_identity_v0alpha1_TeamBindingList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamBinding"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamBinding", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_identity_v0alpha1_TeamBindingSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "subjects": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamSubject"), + }, + }, + }, + }, + }, + "teamRef": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamRef"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamRef", "github.com/grafana/grafana/pkg/apis/identity/v0alpha1.TeamSubject"}, + } +} + func schema_pkg_apis_identity_v0alpha1_TeamList(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -496,6 +621,25 @@ func schema_pkg_apis_identity_v0alpha1_TeamList(ref common.ReferenceCallback) co } } +func schema_pkg_apis_identity_v0alpha1_TeamRef(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Description: "Name is the unique identifier for a team.", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + func schema_pkg_apis_identity_v0alpha1_TeamSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -520,6 +664,33 @@ func schema_pkg_apis_identity_v0alpha1_TeamSpec(ref common.ReferenceCallback) co } } +func schema_pkg_apis_identity_v0alpha1_TeamSubject(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Description: "Name is the unique identifier for subject.", + Type: []string{"string"}, + Format: "", + }, + }, + "permission": { + SchemaProps: spec.SchemaProps{ + Description: "Permission subject has in permission. Can be either admin or member.\n\nPossible enum values:\n - `\"admin\"`\n - `\"member\"`", + Type: []string{"string"}, + Format: "", + Enum: []interface{}{"admin", "member"}, + }, + }, + }, + }, + }, + } +} + func schema_pkg_apis_identity_v0alpha1_User(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/pkg/apis/identity/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/identity/v0alpha1/zz_generated.openapi_violation_exceptions.list index 90ecfe61684..429f6a92576 100644 --- a/pkg/apis/identity/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/pkg/apis/identity/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -1,3 +1,4 @@ +API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/identity/v0alpha1,TeamBindingSpec,Subjects API rule violation: names_match,github.com/grafana/grafana/pkg/apis/identity/v0alpha1,IdentityDisplay,IdentityType API rule violation: names_match,github.com/grafana/grafana/pkg/apis/identity/v0alpha1,IdentityDisplay,InternalID API rule violation: names_match,github.com/grafana/grafana/pkg/apis/identity/v0alpha1,TeamSpec,Title diff --git a/pkg/registry/apis/identity/common/common.go b/pkg/registry/apis/identity/common/common.go new file mode 100644 index 00000000000..25d7066db7e --- /dev/null +++ b/pkg/registry/apis/identity/common/common.go @@ -0,0 +1,30 @@ +package common + +import ( + "fmt" + "strconv" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" +) + +// GetContinueID is a helper to parse options.Continue as int64. +// If no continue token is provided 0 is returned. +func GetContinueID(options *internalversion.ListOptions) (int64, error) { + if options.Continue != "" { + continueID, err := strconv.ParseInt(options.Continue, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid continue token: %w", err) + } + return continueID, nil + } + return 0, nil +} + +// OptonalFormatInt formats num as a string. If num is less or equal than 0 +// an empty string is returned. +func OptionalFormatInt(num int64) string { + if num > 0 { + return strconv.FormatInt(num, 10) + } + return "" +} diff --git a/pkg/registry/apis/identity/legacy/legacy_sql.go b/pkg/registry/apis/identity/legacy/legacy_sql.go index b768b39b5a6..7380d0c4da9 100644 --- a/pkg/registry/apis/identity/legacy/legacy_sql.go +++ b/pkg/registry/apis/identity/legacy/legacy_sql.go @@ -16,16 +16,16 @@ var ( _ LegacyIdentityStore = (*legacySQLStore)(nil) ) -type legacySQLStore struct { - sql legacysql.LegacyDatabaseProvider -} - func NewLegacySQLStores(sql legacysql.LegacyDatabaseProvider) LegacyIdentityStore { return &legacySQLStore{ sql: sql, } } +type legacySQLStore struct { + sql legacysql.LegacyDatabaseProvider +} + // ListTeams implements LegacyIdentityStore. func (s *legacySQLStore) ListTeams(ctx context.Context, ns claims.NamespaceInfo, query ListTeamQuery) (*ListTeamResult, error) { if query.Limit < 1 { @@ -51,8 +51,6 @@ func (s *legacySQLStore) ListTeams(ctx context.Context, ns claims.NamespaceInfo, } q := rawQuery - // fmt.Printf("%s // %v\n", rawQuery, req.GetArgs()) - res := &ListTeamResult{} rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...) defer func() { @@ -126,13 +124,10 @@ func (s *legacySQLStore) GetDisplay(ctx context.Context, ns claims.NamespaceInfo } func (s *legacySQLStore) queryUsers(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, t *template.Template, req sqltemplate.Args, limit int) (*ListUserResult, error) { - rawQuery, err := sqltemplate.Execute(t, req) + q, err := sqltemplate.Execute(t, req) if err != nil { return nil, fmt.Errorf("execute template %q: %w", sqlQueryUsers.Name(), err) } - q := rawQuery - - // fmt.Printf("%s // %v\n", rawQuery, req.GetArgs()) res := &ListUserResult{} rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...) @@ -163,6 +158,83 @@ func (s *legacySQLStore) queryUsers(ctx context.Context, sql *legacysql.LegacyDa return res, err } +// ListTeamsBindings implements LegacyIdentityStore. +func (s *legacySQLStore) ListTeamBindings(ctx context.Context, ns claims.NamespaceInfo, query ListTeamBindingsQuery) (*ListTeamBindingsResult, error) { + if query.Limit < 1 { + query.Limit = 50 + } + + limit := int(query.Limit) + query.Limit += 1 // for continue + query.OrgID = ns.OrgID + if query.OrgID == 0 { + return nil, fmt.Errorf("expected non zero orgID") + } + + sql, err := s.sql(ctx) + if err != nil { + return nil, err + } + + req := newListTeamBindings(sql, &query) + q, err := sqltemplate.Execute(sqlQueryTeamBindings, req) + if err != nil { + return nil, fmt.Errorf("execute template %q: %w", sqlQueryTeams.Name(), err) + } + + rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...) + defer func() { + if rows != nil { + _ = rows.Close() + } + }() + + if err != nil { + return nil, err + } + + res := &ListTeamBindingsResult{} + grouped := map[string][]TeamMember{} + + var lastID int64 + var atTeamLimit bool + + for rows.Next() { + m := TeamMember{} + err = rows.Scan(&m.ID, &m.TeamUID, &m.TeamID, &m.UserUID, &m.Created, &m.Updated, &m.Permission) + if err != nil { + return res, err + } + + lastID = m.TeamID + members, ok := grouped[m.TeamUID] + if ok { + grouped[m.TeamUID] = append(members, m) + } else if !atTeamLimit { + grouped[m.TeamUID] = []TeamMember{m} + } + + if len(grouped) >= limit { + atTeamLimit = true + res.ContinueID = lastID + } + } + + if query.UID == "" { + res.RV, err = sql.GetResourceVersion(ctx, "team_member", "updated") + } + + res.Bindings = make([]TeamBinding, 0, len(grouped)) + for uid, members := range grouped { + res.Bindings = append(res.Bindings, TeamBinding{ + TeamUID: uid, + Members: members, + }) + } + + return res, err +} + // GetUserTeams implements LegacyIdentityStore. func (s *legacySQLStore) GetUserTeams(ctx context.Context, ns claims.NamespaceInfo, uid string) ([]team.Team, error) { panic("unimplemented") diff --git a/pkg/registry/apis/identity/legacy/queries.go b/pkg/registry/apis/identity/legacy/queries.go index 442e2a8b0f1..a1127b91dd3 100644 --- a/pkg/registry/apis/identity/legacy/queries.go +++ b/pkg/registry/apis/identity/legacy/queries.go @@ -26,9 +26,10 @@ func mustTemplate(filename string) *template.Template { // Templates. var ( - sqlQueryTeams = mustTemplate("query_teams.sql") - sqlQueryUsers = mustTemplate("query_users.sql") - sqlQueryDisplay = mustTemplate("query_display.sql") + sqlQueryTeams = mustTemplate("query_teams.sql") + sqlQueryUsers = mustTemplate("query_users.sql") + sqlQueryDisplay = mustTemplate("query_display.sql") + sqlQueryTeamBindings = mustTemplate("query_team_bindings.sql") ) type sqlQueryListUsers struct { @@ -88,3 +89,25 @@ func newGetDisplay(sql *legacysql.LegacyDatabaseHelper, q *GetUserDisplayQuery) func (r sqlQueryGetDisplay) Validate() error { return nil // TODO } + +type sqlQueryListTeamBindings struct { + sqltemplate.SQLTemplate + Query *ListTeamBindingsQuery + UserTable string + TeamTable string + TeamMemberTable string +} + +func (r sqlQueryListTeamBindings) Validate() error { + return nil // TODO +} + +func newListTeamBindings(sql *legacysql.LegacyDatabaseHelper, q *ListTeamBindingsQuery) sqlQueryListTeamBindings { + return sqlQueryListTeamBindings{ + SQLTemplate: sqltemplate.New(sql.DialectForDriver()), + UserTable: sql.Table("user"), + TeamTable: sql.Table("team"), + TeamMemberTable: sql.Table("team_member"), + Query: q, + } +} diff --git a/pkg/registry/apis/identity/legacy/queries_test.go b/pkg/registry/apis/identity/legacy/queries_test.go index 7cc0baf6e20..2df6e1f8e16 100644 --- a/pkg/registry/apis/identity/legacy/queries_test.go +++ b/pkg/registry/apis/identity/legacy/queries_test.go @@ -35,6 +35,12 @@ func TestQueries(t *testing.T) { return &v } + listTeamBindings := func(q *ListTeamBindingsQuery) sqltemplate.SQLTemplate { + v := newListTeamBindings(nodb, q) + v.SQLTemplate = mocks.NewTestingSQLTemplate() + return &v + } + mocks.CheckQuerySnapshots(t, mocks.TemplateTestSetup{ RootDir: "testdata", Templates: map[*template.Template][]mocks.TemplateTestCase{ @@ -104,6 +110,30 @@ func TestQueries(t *testing.T) { }), }, }, + sqlQueryTeamBindings: { + { + Name: "team_1_bindings", + Data: listTeamBindings(&ListTeamBindingsQuery{ + OrgID: 1, + UID: "team-1", + }), + }, + { + Name: "team_bindings_page_1", + Data: listTeamBindings(&ListTeamBindingsQuery{ + OrgID: 1, + Limit: 5, + }), + }, + { + Name: "team_bindings_page_2", + Data: listTeamBindings(&ListTeamBindingsQuery{ + OrgID: 1, + Limit: 5, + ContinueID: 5, + }), + }, + }, }, }) } diff --git a/pkg/registry/apis/identity/legacy/query_team_bindings.sql b/pkg/registry/apis/identity/legacy/query_team_bindings.sql new file mode 100644 index 00000000000..4176247a592 --- /dev/null +++ b/pkg/registry/apis/identity/legacy/query_team_bindings.sql @@ -0,0 +1,18 @@ +SELECT tm.id as id, t.uid as team_uid, t.id as team_id, u.uid as user_uid, tm.created, tm.updated, tm.permission FROM {{ .Ident .TeamMemberTable }} tm +INNER JOIN {{ .Ident .TeamTable }} t ON tm.team_id = t.id +INNER JOIN {{ .Ident .UserTable }} u ON tm.user_id = u.id +WHERE +{{ if .Query.UID }} + t.uid = {{ .Arg .Query.UID }} +{{ else }} + t.uid IN( + SELECT uid + FROM {{ .Ident .TeamTable }} t + {{ if .Query.ContinueID }} + WHERE t.id >= {{ .Arg .Query.ContinueID }} + {{ end }} + ORDER BY t.id ASC LIMIT {{ .Arg .Query.Limit }} + ) +{{ end }} +AND tm.org_id = {{ .Arg .Query.OrgID}} +ORDER BY t.id ASC; diff --git a/pkg/registry/apis/identity/legacy/testdata/mysql--query_team_bindings-team_1_bindings.sql b/pkg/registry/apis/identity/legacy/testdata/mysql--query_team_bindings-team_1_bindings.sql new file mode 100755 index 00000000000..5c0f06a1720 --- /dev/null +++ b/pkg/registry/apis/identity/legacy/testdata/mysql--query_team_bindings-team_1_bindings.sql @@ -0,0 +1,7 @@ +SELECT tm.id as id, t.uid as team_uid, t.id as team_id, u.uid as user_uid, tm.created, tm.updated, tm.permission FROM "grafana.team_member" tm +INNER JOIN "grafana.team" t ON tm.team_id = t.id +INNER JOIN "grafana.user" u ON tm.user_id = u.id +WHERE + t.uid = 'team-1' +AND tm.org_id = 1 +ORDER BY t.id ASC; diff --git a/pkg/registry/apis/identity/legacy/testdata/mysql--query_team_bindings-team_bindings_page_1.sql b/pkg/registry/apis/identity/legacy/testdata/mysql--query_team_bindings-team_bindings_page_1.sql new file mode 100755 index 00000000000..3cea257a936 --- /dev/null +++ b/pkg/registry/apis/identity/legacy/testdata/mysql--query_team_bindings-team_bindings_page_1.sql @@ -0,0 +1,11 @@ +SELECT tm.id as id, t.uid as team_uid, t.id as team_id, u.uid as user_uid, tm.created, tm.updated, tm.permission FROM "grafana.team_member" tm +INNER JOIN "grafana.team" t ON tm.team_id = t.id +INNER JOIN "grafana.user" u ON tm.user_id = u.id +WHERE + t.uid IN( + SELECT uid + FROM "grafana.team" t + ORDER BY t.id ASC LIMIT 5 + ) +AND tm.org_id = 1 +ORDER BY t.id ASC; diff --git a/pkg/registry/apis/identity/legacy/testdata/mysql--query_team_bindings-team_bindings_page_2.sql b/pkg/registry/apis/identity/legacy/testdata/mysql--query_team_bindings-team_bindings_page_2.sql new file mode 100755 index 00000000000..b9a4fc91939 --- /dev/null +++ b/pkg/registry/apis/identity/legacy/testdata/mysql--query_team_bindings-team_bindings_page_2.sql @@ -0,0 +1,12 @@ +SELECT tm.id as id, t.uid as team_uid, t.id as team_id, u.uid as user_uid, tm.created, tm.updated, tm.permission FROM "grafana.team_member" tm +INNER JOIN "grafana.team" t ON tm.team_id = t.id +INNER JOIN "grafana.user" u ON tm.user_id = u.id +WHERE + t.uid IN( + SELECT uid + FROM "grafana.team" t + WHERE t.id >= 5 + ORDER BY t.id ASC LIMIT 5 + ) +AND tm.org_id = 1 +ORDER BY t.id ASC; diff --git a/pkg/registry/apis/identity/legacy/testdata/postgres--query_team_bindings-team_1_bindings.sql b/pkg/registry/apis/identity/legacy/testdata/postgres--query_team_bindings-team_1_bindings.sql new file mode 100755 index 00000000000..5c0f06a1720 --- /dev/null +++ b/pkg/registry/apis/identity/legacy/testdata/postgres--query_team_bindings-team_1_bindings.sql @@ -0,0 +1,7 @@ +SELECT tm.id as id, t.uid as team_uid, t.id as team_id, u.uid as user_uid, tm.created, tm.updated, tm.permission FROM "grafana.team_member" tm +INNER JOIN "grafana.team" t ON tm.team_id = t.id +INNER JOIN "grafana.user" u ON tm.user_id = u.id +WHERE + t.uid = 'team-1' +AND tm.org_id = 1 +ORDER BY t.id ASC; diff --git a/pkg/registry/apis/identity/legacy/testdata/postgres--query_team_bindings-team_bindings_page_1.sql b/pkg/registry/apis/identity/legacy/testdata/postgres--query_team_bindings-team_bindings_page_1.sql new file mode 100755 index 00000000000..3cea257a936 --- /dev/null +++ b/pkg/registry/apis/identity/legacy/testdata/postgres--query_team_bindings-team_bindings_page_1.sql @@ -0,0 +1,11 @@ +SELECT tm.id as id, t.uid as team_uid, t.id as team_id, u.uid as user_uid, tm.created, tm.updated, tm.permission FROM "grafana.team_member" tm +INNER JOIN "grafana.team" t ON tm.team_id = t.id +INNER JOIN "grafana.user" u ON tm.user_id = u.id +WHERE + t.uid IN( + SELECT uid + FROM "grafana.team" t + ORDER BY t.id ASC LIMIT 5 + ) +AND tm.org_id = 1 +ORDER BY t.id ASC; diff --git a/pkg/registry/apis/identity/legacy/testdata/postgres--query_team_bindings-team_bindings_page_2.sql b/pkg/registry/apis/identity/legacy/testdata/postgres--query_team_bindings-team_bindings_page_2.sql new file mode 100755 index 00000000000..b9a4fc91939 --- /dev/null +++ b/pkg/registry/apis/identity/legacy/testdata/postgres--query_team_bindings-team_bindings_page_2.sql @@ -0,0 +1,12 @@ +SELECT tm.id as id, t.uid as team_uid, t.id as team_id, u.uid as user_uid, tm.created, tm.updated, tm.permission FROM "grafana.team_member" tm +INNER JOIN "grafana.team" t ON tm.team_id = t.id +INNER JOIN "grafana.user" u ON tm.user_id = u.id +WHERE + t.uid IN( + SELECT uid + FROM "grafana.team" t + WHERE t.id >= 5 + ORDER BY t.id ASC LIMIT 5 + ) +AND tm.org_id = 1 +ORDER BY t.id ASC; diff --git a/pkg/registry/apis/identity/legacy/testdata/sqlite--query_team_bindings-team_1_bindings.sql b/pkg/registry/apis/identity/legacy/testdata/sqlite--query_team_bindings-team_1_bindings.sql new file mode 100755 index 00000000000..5c0f06a1720 --- /dev/null +++ b/pkg/registry/apis/identity/legacy/testdata/sqlite--query_team_bindings-team_1_bindings.sql @@ -0,0 +1,7 @@ +SELECT tm.id as id, t.uid as team_uid, t.id as team_id, u.uid as user_uid, tm.created, tm.updated, tm.permission FROM "grafana.team_member" tm +INNER JOIN "grafana.team" t ON tm.team_id = t.id +INNER JOIN "grafana.user" u ON tm.user_id = u.id +WHERE + t.uid = 'team-1' +AND tm.org_id = 1 +ORDER BY t.id ASC; diff --git a/pkg/registry/apis/identity/legacy/testdata/sqlite--query_team_bindings-team_bindings_page_1.sql b/pkg/registry/apis/identity/legacy/testdata/sqlite--query_team_bindings-team_bindings_page_1.sql new file mode 100755 index 00000000000..3cea257a936 --- /dev/null +++ b/pkg/registry/apis/identity/legacy/testdata/sqlite--query_team_bindings-team_bindings_page_1.sql @@ -0,0 +1,11 @@ +SELECT tm.id as id, t.uid as team_uid, t.id as team_id, u.uid as user_uid, tm.created, tm.updated, tm.permission FROM "grafana.team_member" tm +INNER JOIN "grafana.team" t ON tm.team_id = t.id +INNER JOIN "grafana.user" u ON tm.user_id = u.id +WHERE + t.uid IN( + SELECT uid + FROM "grafana.team" t + ORDER BY t.id ASC LIMIT 5 + ) +AND tm.org_id = 1 +ORDER BY t.id ASC; diff --git a/pkg/registry/apis/identity/legacy/testdata/sqlite--query_team_bindings-team_bindings_page_2.sql b/pkg/registry/apis/identity/legacy/testdata/sqlite--query_team_bindings-team_bindings_page_2.sql new file mode 100755 index 00000000000..b9a4fc91939 --- /dev/null +++ b/pkg/registry/apis/identity/legacy/testdata/sqlite--query_team_bindings-team_bindings_page_2.sql @@ -0,0 +1,12 @@ +SELECT tm.id as id, t.uid as team_uid, t.id as team_id, u.uid as user_uid, tm.created, tm.updated, tm.permission FROM "grafana.team_member" tm +INNER JOIN "grafana.team" t ON tm.team_id = t.id +INNER JOIN "grafana.user" u ON tm.user_id = u.id +WHERE + t.uid IN( + SELECT uid + FROM "grafana.team" t + WHERE t.id >= 5 + ORDER BY t.id ASC LIMIT 5 + ) +AND tm.org_id = 1 +ORDER BY t.id ASC; diff --git a/pkg/registry/apis/identity/legacy/types.go b/pkg/registry/apis/identity/legacy/types.go index 289e5176363..08b42434c17 100644 --- a/pkg/registry/apis/identity/legacy/types.go +++ b/pkg/registry/apis/identity/legacy/types.go @@ -2,8 +2,10 @@ package legacy import ( "context" + "time" "github.com/grafana/authlib/claims" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/user" ) @@ -22,6 +24,12 @@ type ListUserResult struct { RV int64 } +type GetUserDisplayQuery struct { + OrgID int64 + UIDs []string + IDs []int64 +} + type ListTeamQuery struct { OrgID int64 UID string @@ -35,16 +43,46 @@ type ListTeamResult struct { RV int64 } -type GetUserDisplayQuery struct { - OrgID int64 - UIDs []string - IDs []int64 +type TeamMember struct { + ID int64 + TeamID int64 + TeamUID string + UserUID string + Updated time.Time + Created time.Time + Permission team.PermissionType +} + +func (m TeamMember) MemberID() string { + return identity.NewTypedIDString(claims.TypeUser, m.UserUID) +} + +type TeamBinding struct { + TeamUID string + Members []TeamMember +} + +type ListTeamBindingsQuery struct { + // UID is team uid to list bindings for. If not set store should list bindings for all teams + UID string + OrgID int64 + ContinueID int64 // ContinueID + Limit int64 +} + +type ListTeamBindingsResult struct { + Bindings []TeamBinding + ContinueID int64 + RV int64 } // In every case, RBAC should be applied before calling, or before returning results to the requester type LegacyIdentityStore interface { ListUsers(ctx context.Context, ns claims.NamespaceInfo, query ListUserQuery) (*ListUserResult, error) - ListTeams(ctx context.Context, ns claims.NamespaceInfo, query ListTeamQuery) (*ListTeamResult, error) GetDisplay(ctx context.Context, ns claims.NamespaceInfo, query GetUserDisplayQuery) (*ListUserResult, error) + + ListTeams(ctx context.Context, ns claims.NamespaceInfo, query ListTeamQuery) (*ListTeamResult, error) + ListTeamBindings(ctx context.Context, ns claims.NamespaceInfo, query ListTeamBindingsQuery) (*ListTeamBindingsResult, error) + GetUserTeams(ctx context.Context, ns claims.NamespaceInfo, uid string) ([]team.Team, error) } diff --git a/pkg/registry/apis/identity/register.go b/pkg/registry/apis/identity/register.go index a39e59fae19..22079582c7b 100644 --- a/pkg/registry/apis/identity/register.go +++ b/pkg/registry/apis/identity/register.go @@ -83,6 +83,9 @@ func (b *IdentityAPIBuilder) GetAPIGroupInfo( teamResource := identityv0.TeamResourceInfo storage[teamResource.StoragePath()] = team.NewLegacyStore(b.Store) + teamBindingResource := identityv0.TeamBindingResourceInfo + storage[teamBindingResource.StoragePath()] = team.NewLegacyBindingStore(b.Store) + userResource := identityv0.UserResourceInfo storage[userResource.StoragePath()] = user.NewLegacyStore(b.Store) storage[userResource.StoragePath("teams")] = team.NewLegacyUserTeamsStore(b.Store) diff --git a/pkg/registry/apis/identity/team/store_binding.go b/pkg/registry/apis/identity/team/store_binding.go new file mode 100644 index 00000000000..480cbe90c8e --- /dev/null +++ b/pkg/registry/apis/identity/team/store_binding.go @@ -0,0 +1,171 @@ +package team + +import ( + "context" + "strconv" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/grafana/authlib/claims" + identityv0 "github.com/grafana/grafana/pkg/apis/identity/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/identity/common" + "github.com/grafana/grafana/pkg/registry/apis/identity/legacy" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/team" +) + +var bindingResource = identityv0.TeamBindingResourceInfo + +var ( + _ rest.Storage = (*LegacyBindingStore)(nil) + _ rest.Scoper = (*LegacyBindingStore)(nil) + _ rest.SingularNameProvider = (*LegacyBindingStore)(nil) + _ rest.Getter = (*LegacyBindingStore)(nil) + _ rest.Lister = (*LegacyBindingStore)(nil) +) + +func NewLegacyBindingStore(store legacy.LegacyIdentityStore) *LegacyBindingStore { + return &LegacyBindingStore{store} +} + +type LegacyBindingStore struct { + store legacy.LegacyIdentityStore +} + +// Destroy implements rest.Storage. +func (l *LegacyBindingStore) Destroy() {} + +// New implements rest.Storage. +func (l *LegacyBindingStore) New() runtime.Object { + return bindingResource.NewFunc() +} + +// NewList implements rest.Lister. +func (l *LegacyBindingStore) NewList() runtime.Object { + return bindingResource.NewListFunc() +} + +// NamespaceScoped implements rest.Scoper. +func (l *LegacyBindingStore) NamespaceScoped() bool { + return true +} + +// GetSingularName implements rest.SingularNameProvider. +func (l *LegacyBindingStore) GetSingularName() string { + return bindingResource.GetSingularName() +} + +// ConvertToTable implements rest.Lister. +func (l *LegacyBindingStore) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return bindingResource.TableConverter().ConvertToTable(ctx, object, tableOptions) +} + +// Get implements rest.Getter. +func (l *LegacyBindingStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + ns, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + res, err := l.store.ListTeamBindings(ctx, ns, legacy.ListTeamBindingsQuery{ + UID: name, + Limit: 1, + }) + if err != nil { + return nil, err + } + + if len(res.Bindings) != 1 { + // FIXME: maybe empty result? + return nil, resource.NewNotFound(name) + } + + obj := mapToBindingObject(ns, res.Bindings[0]) + return &obj, nil +} + +// List implements rest.Lister. +func (l *LegacyBindingStore) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + ns, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + continueID, err := common.GetContinueID(options) + if err != nil { + return nil, err + } + + res, err := l.store.ListTeamBindings(ctx, ns, legacy.ListTeamBindingsQuery{ + ContinueID: continueID, + Limit: options.Limit, + }) + if err != nil { + return nil, err + } + + list := identityv0.TeamBindingList{ + Items: make([]identityv0.TeamBinding, 0, len(res.Bindings)), + } + + for _, b := range res.Bindings { + list.Items = append(list.Items, mapToBindingObject(ns, b)) + } + + list.ListMeta.Continue = common.OptionalFormatInt(res.ContinueID) + list.ListMeta.ResourceVersion = common.OptionalFormatInt(res.RV) + + return &list, nil +} + +func mapToBindingObject(ns claims.NamespaceInfo, b legacy.TeamBinding) identityv0.TeamBinding { + rv := time.Time{} + ct := time.Now() + + for _, m := range b.Members { + if m.Updated.After(rv) { + rv = m.Updated + } + if m.Created.Before(ct) { + ct = m.Created + } + } + + return identityv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: b.TeamUID, + Namespace: ns.Value, + ResourceVersion: strconv.FormatInt(rv.UnixMilli(), 10), + CreationTimestamp: metav1.NewTime(ct), + }, + Spec: identityv0.TeamBindingSpec{ + TeamRef: identityv0.TeamRef{ + Name: b.TeamUID, + }, + Subjects: mapToSubjects(b.Members), + }, + } +} + +func mapToSubjects(members []legacy.TeamMember) []identityv0.TeamSubject { + out := make([]identityv0.TeamSubject, 0, len(members)) + for _, m := range members { + out = append(out, identityv0.TeamSubject{ + Name: m.MemberID(), + Permission: mapPermisson(m.Permission), + }) + } + return out +} + +func mapPermisson(p team.PermissionType) identityv0.TeamPermission { + if p == team.PermissionTypeAdmin { + return identityv0.TeamPermissionAdmin + } else { + return identityv0.TeamPermissionMember + } +} diff --git a/pkg/registry/apis/identity/team/user_team_store.go b/pkg/registry/apis/identity/team/store_user_team.go similarity index 100% rename from pkg/registry/apis/identity/team/user_team_store.go rename to pkg/registry/apis/identity/team/store_user_team.go