diff --git a/CHANGELOG.md b/CHANGELOG.md index 95beb6963..f7014f512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan - [#161](https://github.com/kobsio/kobs/pull/161): Add support for materialized columns, to improve query performance for most frequently queried field. - [#162](https://github.com/kobsio/kobs/pull/162): Add support to visualize logs in the ClickHouse plugin. - [#170](https://github.com/kobsio/kobs/pull/170): Add Custom Resource Definition for Users. +- [#171](https://github.com/kobsio/kobs/pull/171): :warning: _Breaking change:_ :warning: Add authentication and authorization mechanism for plugins and resources. These settings are configured via the `--api.auth.` flags. Permissions are always granted on a team level. A users gets all permissions of the team where he is a member of. ### Fixed diff --git a/deploy/helm/kobs/Chart.yaml b/deploy/helm/kobs/Chart.yaml index 22c27a639..746544993 100644 --- a/deploy/helm/kobs/Chart.yaml +++ b/deploy/helm/kobs/Chart.yaml @@ -4,5 +4,5 @@ description: Kubernetes Observability Platform type: application home: https://kobs.io icon: https://kobs.io/assets/images/logo.svg -version: 0.7.0 +version: 0.7.1 appVersion: v0.5.0 diff --git a/deploy/helm/kobs/crds/kobs.io_teams.yaml b/deploy/helm/kobs/crds/kobs.io_teams.yaml index 0032c04d7..85e184e70 100644 --- a/deploy/helm/kobs/crds/kobs.io_teams.yaml +++ b/deploy/helm/kobs/crds/kobs.io_teams.yaml @@ -147,6 +147,37 @@ spec: type: string namespace: type: string + permissions: + properties: + plugins: + items: + type: string + type: array + resources: + items: + properties: + clusters: + items: + type: string + type: array + namespaces: + items: + type: string + type: array + resources: + items: + type: string + type: array + required: + - clusters + - namespaces + - resources + type: object + type: array + required: + - plugins + - resources + type: object type: object type: object served: true diff --git a/deploy/kustomize/crds/kobs.io_teams.yaml b/deploy/kustomize/crds/kobs.io_teams.yaml index 0032c04d7..85e184e70 100644 --- a/deploy/kustomize/crds/kobs.io_teams.yaml +++ b/deploy/kustomize/crds/kobs.io_teams.yaml @@ -147,6 +147,37 @@ spec: type: string namespace: type: string + permissions: + properties: + plugins: + items: + type: string + type: array + resources: + items: + properties: + clusters: + items: + type: string + type: array + namespaces: + items: + type: string + type: array + resources: + items: + type: string + type: array + required: + - clusters + - namespaces + - resources + type: object + type: array + required: + - plugins + - resources + type: object type: object type: object served: true diff --git a/docs/configuration/getting-started.md b/docs/configuration/getting-started.md index 240faf8b7..aa087f017 100644 --- a/docs/configuration/getting-started.md +++ b/docs/configuration/getting-started.md @@ -9,7 +9,10 @@ The following command-line arguments and environment variables are available. | Command-line Argument | Environment Variable | Description | Default | | --------------------- | -------------------- | ----------- | ------- | | `--api.address` | `KOBS_API_ADDRESS` | The address, where the API server is listen on. | `:15220` | -| `--api.auth-header` | `KOBS_API_AUTH_HEADER` | The header, which contains the details about the authenticated user. More information can be found in the [Authentication](authentication.md) section. | `X-Auth-Request-Email` | +| `--api.auth.default-team` | `KOBS_API_AUTH_DEFAULT_TEAM` | The name of the team, which should be used for a users permissions when a user hasn't any teams. The team is specified in the following format: `cluster,namespace,name` | | +| `--api.auth.enabled` | | Enable the authentication and authorization middleware. | `false` | +| `--api.auth.header` | `KOBS_API_AUTH_HEADER` | The header, which contains the details about the authenticated user. More information can be found in the [Authentication](authentication.md) section. | `X-Auth-Request-Email` | +| `--api.auth.interval` | `KOBS_API_AUTH_INTERVAL` | The interval to refresh the internal users list and there permissions. | `1h0m0s` | | `--app.address` | `KOBS_APP_ADDRESS` | The address, where the Application server is listen on. | `:15219` | | `--app.assets` | `KOBS_APP_ASSETS` | The location of the assets directory. | `app/build` | | `--clusters.cache-duration.namespaces` | `KOBS_CLUSTERS_CACHE_DURATION_NAMESPACES` | The duration, for how long requests to get the list of namespaces should be cached. | `5m` | diff --git a/docs/resources/teams.md b/docs/resources/teams.md index 7ce9137a6..1aa6bb415 100644 --- a/docs/resources/teams.md +++ b/docs/resources/teams.md @@ -12,17 +12,33 @@ In the following you can found the specification for the Team CRD. | Field | Type | Description | Required | | ----- | ---- | ----------- | -------- | -| description | string | A description for the team. | Yes | -| logo | string | The logo for the team. Must be a path to an image file. | Yes | +| description | string | A description for the team. | No | +| logo | string | The logo for the team. Must be a path to an image file. | No | | links | [[]Link](#link) | A list of links (e.g. a link to the teams Slack channel, Confluence page, etc.) | No | +| permissions | [Permissions](#permissions) | Permissions for the members of this team, when authentication and authorization is enabled. | No | | dashboards | [[]Dashboard](#dashboard) | No | ### Link | Field | Type | Description | Required | | ----- | ---- | ----------- | -------- | -| title | string | Title for the link | Yes | -| link | string | The actuall link | Yes | +| title | string | Title for the link. | Yes | +| link | string | The actuall link. | Yes | + +### Permissions + +| Field | Type | Description | Required | +| ----- | ---- | ----------- | -------- | +| plugins | []string | A list of plugins, which can be accessed by the members of the team. The special list entry `*` allows access to all plugins. | Yes | +| resources | [[]PermissionResources](#permissionresources) | A list of resources, which can be accessed by the members of the team. | Yes | + +### PermissionResources + +| Field | Type | Description | Required | +| ----- | ---- | ----------- | -------- | +| clusters | []string | A list of clusters to allow access to. The special list entry `*` allows access to all clusters. | Yes | +| namespaces | []string | A list of namespaces to allow access to. The special list entry `*` allows access to all namespaces. | Yes | +| resources | []string | A list of resources to allow access to. The special list entry `*` allows access to all resources. | Yes | ### Dashboard @@ -77,3 +93,25 @@ spec: namespace: bookinfo pod: ".*" ``` + +The following Team CR allows all members of `team-diablo` access to all plugins and resources, when authentication and authorization is enabled. + +```yaml +--- +apiVersion: kobs.io/v1beta1 +kind: Team +metadata: + name: team-diablo + namespace: kobs +spec: + permissions: + plugins: + - "*" + resources: + - clusters: + - "*" + namespaces: + - "*" + resources: + - "*" +``` diff --git a/docs/resources/users.md b/docs/resources/users.md index b76bf7606..4f60f40ca 100644 --- a/docs/resources/users.md +++ b/docs/resources/users.md @@ -12,7 +12,7 @@ In the following you can found the specification for the User CRD. | Field | Type | Description | Required | | ----- | ---- | ----------- | -------- | -| id | string | A unique id for the user. The id must be unique across all clusters and namespace. | Yes | +| id | string | A unique id for the user. The id must be unique across all clusters and namespace. If authentication and authorization is enabled this should be the value passed in the configured user details header (`--api.auth.header`). | Yes | | fullName | string | The full name of the user. | Yes | | email | string | The email address of the user. | Yes | | position | string | The position of the user. | No | diff --git a/pkg/api/api.go b/pkg/api/api.go index 14a128d6a..7d441d174 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -92,10 +92,11 @@ func New(loadedClusters *clusters.Clusters, pluginsRouter chi.Router, isDevelopm r.Use(middleware.Recoverer) r.Use(middleware.URLFormat) r.Use(metrics.Metrics) - r.Use(auth.Auth) + r.Use(auth.Handler(loadedClusters)) r.Use(httplog.NewStructuredLogger(log.Logger)) r.Use(render.SetContentType(render.ContentTypeJSON)) + r.Get("/user", auth.UserHandler) r.Mount("/clusters", clusters.NewRouter(loadedClusters)) r.Mount("/plugins", pluginsRouter) }) diff --git a/pkg/api/apis/team/v1beta1/types.go b/pkg/api/apis/team/v1beta1/types.go index f96719266..2d4af17d5 100644 --- a/pkg/api/apis/team/v1beta1/types.go +++ b/pkg/api/apis/team/v1beta1/types.go @@ -34,6 +34,7 @@ type TeamSpec struct { Description string `json:"description,omitempty"` Links []Link `json:"links,omitempty"` Logo string `json:"logo,omitempty"` + Permissions Permissions `json:"permissions,omitempty"` Dashboards []dashboard.Reference `json:"dashboards,omitempty"` } @@ -48,3 +49,14 @@ type Reference struct { Name string `json:"name"` Description string `json:"description,omitempty"` } + +type Permissions struct { + Plugins []string `json:"plugins"` + Resources []PermissionsResources `json:"resources"` +} + +type PermissionsResources struct { + Clusters []string `json:"clusters"` + Namespaces []string `json:"namespaces"` + Resources []string `json:"resources"` +} diff --git a/pkg/api/apis/team/v1beta1/zz_generated.deepcopy.go b/pkg/api/apis/team/v1beta1/zz_generated.deepcopy.go index cde4d5c39..0db9e2ac4 100644 --- a/pkg/api/apis/team/v1beta1/zz_generated.deepcopy.go +++ b/pkg/api/apis/team/v1beta1/zz_generated.deepcopy.go @@ -42,6 +42,65 @@ func (in *Link) DeepCopy() *Link { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Permissions) DeepCopyInto(out *Permissions) { + *out = *in + if in.Plugins != nil { + in, out := &in.Plugins, &out.Plugins + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]PermissionsResources, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Permissions. +func (in *Permissions) DeepCopy() *Permissions { + if in == nil { + return nil + } + out := new(Permissions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PermissionsResources) DeepCopyInto(out *PermissionsResources) { + *out = *in + if in.Clusters != nil { + in, out := &in.Clusters, &out.Clusters + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Namespaces != nil { + in, out := &in.Namespaces, &out.Namespaces + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PermissionsResources. +func (in *PermissionsResources) DeepCopy() *PermissionsResources { + if in == nil { + return nil + } + out := new(PermissionsResources) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Reference) DeepCopyInto(out *Reference) { *out = *in @@ -126,6 +185,7 @@ func (in *TeamSpec) DeepCopyInto(out *TeamSpec) { *out = make([]Link, len(*in)) copy(*out, *in) } + in.Permissions.DeepCopyInto(&out.Permissions) if in.Dashboards != nil { in, out := &in.Dashboards, &out.Dashboards *out = make([]dashboardv1beta1.Reference, len(*in)) diff --git a/pkg/api/middleware/auth/auth.go b/pkg/api/middleware/auth/auth.go index 1675f7ddb..30ca98ec5 100644 --- a/pkg/api/middleware/auth/auth.go +++ b/pkg/api/middleware/auth/auth.go @@ -3,52 +3,205 @@ package auth import ( "context" "net/http" - "os" + "strings" + "sync" + "time" - flag "github.com/spf13/pflag" + team "github.com/kobsio/kobs/pkg/api/apis/team/v1beta1" + user "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" + "github.com/kobsio/kobs/pkg/api/clusters" + authContext "github.com/kobsio/kobs/pkg/api/middleware/auth/context" + "github.com/kobsio/kobs/pkg/api/middleware/errresponse" + + "github.com/sirupsen/logrus" ) -// Key to use when setting the user. -type ctxKeyUser int +// Auth is the struct for handling authorization for resources. +type Auth struct { + enabled bool + userHeader string + defaultTeam string + refreshInterval time.Duration + clusters *clusters.Clusters + defaultPermissions team.Permissions + users sync.Map +} -// userKey is the key that holds the user in a request context. -const userKey ctxKeyUser = 0 +// Handler apply the authorization policy for a request and adds the user information to the request. +// +// We are always trying to get the user id from the specified authentication header, so that we can log the users which +// runs the request also when authentication is disabled. That way we can have a basic audit log when authentication is +// disabled. +// When authentication is enabled we are checking if the user exists in the users map, if this isn't the case we we +// applie the default permissions for the user. When the user exists we are checking if the user has access to the +// plugin. The API routes which are outside of the plugins router are always accessible (e.g. getting all configured +// plugins and clusters). +func (a *Auth) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userID := r.Header.Get(flagUserHeader) -// UserHeader is the name of the HTTP Header which contains the user information. -var ( - userHeader string -) + if a.enabled { + if userID == "" { + errresponse.Render(w, r, nil, http.StatusUnauthorized, "Unauthorized") + return + } + + var user authContext.User + + u, ok := a.users.Load(userID) + if !ok { + user = authContext.User{ + ID: userID, + HasProfile: false, + Permissions: a.defaultPermissions, + } + } else { + user = u.(authContext.User) + } + + urlPaths := strings.Split(r.URL.Path, "/") + if len(urlPaths) >= 4 && urlPaths[1] == "api" && urlPaths[2] == "plugins" { + if !user.HasPluginAccess(urlPaths[3]) { + errresponse.Render(w, r, nil, http.StatusForbidden, "Your are not allowed to access the plugin") + return + } + } + + ctx = context.WithValue(ctx, authContext.UserKey, user) + } else { + if userID == "" { + userID = "kobs.io" + } -func init() { - defaultHeader := "X-Auth-Request-Email" - if os.Getenv("KOBS_API_AUTH_HEADER") != "" { - defaultHeader = os.Getenv("KOBS_API_AUTH_HEADER") + ctx = context.WithValue(ctx, authContext.UserKey, authContext.User{ + ID: userID, + HasProfile: false, + Permissions: team.Permissions{ + Plugins: []string{"*"}, + Resources: []team.PermissionsResources{{ + Clusters: []string{"*"}, + Namespaces: []string{"*"}, + Resources: []string{"*"}, + }}, + }, + }) + } + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// GetPermissions should be called in a new goroutine to get a list of users and there permissions. This list is +// refreshed by the refresh interval parameter. +// When authentication and authorization isn't enabled this function directly returns. If the auth module is enabled it +// runs the internal getPermissions function on the specified interval. +func (a *Auth) GetPermissions() { + if !a.enabled { + log.Infof("authentication and authorization middleware is disabled") + return } - flag.StringVar(&userHeader, "api.auth-header", defaultHeader, "The header, which contains the details about the authenticated user.") + err := a.getPermissions() + if err != nil { + log.WithError(err).Errorf("failed to refresh users and permissions") + } else { + log.Infof("refreshed users and permissions") + } + + ticker := time.NewTicker(a.refreshInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + err := a.getPermissions() + if err != nil { + log.WithError(err).Errorf("failed to refresh users and permissions") + } else { + log.Infof("refreshed users and permissions") + } + } + } } -// Auth is a middleware that injects the user information from a configured request header. -func Auth(next http.Handler) http.Handler { - fn := func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user := r.Header.Get(userHeader) - ctx = context.WithValue(ctx, userKey, user) - next.ServeHTTP(w, r.WithContext(ctx)) +// getPermissions sets the permissions for each user. For that we are getting all teams and users from the configured +// clusters first. After that we are adding the permissions of the users teams to the user. +func (a *Auth) getPermissions() error { + ctx, cancel := context.WithTimeout(context.Background(), a.refreshInterval) + defer cancel() + + var teams []team.TeamSpec + var users []user.UserSpec + + for _, cluster := range a.clusters.Clusters { + t, err := cluster.GetTeams(ctx, "") + if err != nil { + log.WithError(err).WithFields(logrus.Fields{"cluster": cluster.GetName()}).Warnf("could not get teams") + } + + teams = append(teams, t...) + + u, err := cluster.GetUsers(ctx, "") + if err != nil { + log.WithError(err).WithFields(logrus.Fields{"cluster": cluster.GetName()}).Warnf("could not get users") + } + + users = append(users, u...) + } + + defaultTeam := strings.Split(a.defaultTeam, ",") + if len(defaultTeam) == 3 { + for _, t := range teams { + if t.Cluster == defaultTeam[0] && t.Namespace == defaultTeam[1] && t.Name == defaultTeam[2] { + a.defaultPermissions = t.Permissions + } + } + } + + for _, u := range users { + a.users.Store(u.ID, getUserPermissions(u, teams)) } - return http.HandlerFunc(fn) + return nil } -// GetUser returns a user from the given context if one is present. Returns the empty string if a user can not be found. -func GetUser(ctx context.Context) string { - if ctx == nil { - return "" +func getUserPermissions(user user.UserSpec, teams []team.TeamSpec) authContext.User { + u := authContext.User{ + ID: user.ID, + HasProfile: true, + Profile: user, } - if reqID, ok := ctx.Value(userKey).(string); ok { - return reqID + for _, userTeam := range user.Teams { + c := userTeam.Cluster + if c == "" { + c = user.Cluster + } + + n := userTeam.Namespace + if n == "" { + n = user.Namespace + } + + for _, team := range teams { + if c == team.Cluster && n == team.Namespace && userTeam.Name == team.Name { + u.Permissions.Plugins = append(u.Permissions.Plugins, team.Permissions.Plugins...) + u.Permissions.Resources = append(u.Permissions.Resources, team.Permissions.Resources...) + } + } } - return "" + return u +} + +// New returns a new authentication and authorization object. +func New(enabled bool, userHeader, defaultTeam string, interval time.Duration, clusters *clusters.Clusters) *Auth { + return &Auth{ + enabled: enabled, + userHeader: userHeader, + defaultTeam: defaultTeam, + refreshInterval: interval, + clusters: clusters, + } } diff --git a/pkg/api/middleware/auth/context/context.go b/pkg/api/middleware/auth/context/context.go new file mode 100644 index 000000000..0944c3039 --- /dev/null +++ b/pkg/api/middleware/auth/context/context.go @@ -0,0 +1,99 @@ +package context + +import ( + "context" + "fmt" + + team "github.com/kobsio/kobs/pkg/api/apis/team/v1beta1" + user "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" +) + +// Key to use when setting the user. +type ctxKeyUser int + +// UserKey is the key that holds the user in a request context. +const UserKey ctxKeyUser = 0 + +// User is the structure of the user object saved in the request context. It contains the users id and permissions if +// authentication is enabled. +type User struct { + ID string `json:"id"` + HasProfile bool `json:"hasProfile"` + Profile user.UserSpec `json:"profile,omitempty"` + Permissions team.Permissions `json:"permissions"` +} + +// HasPluginAccess checks if the user has access to the given plugin. +func (u *User) HasPluginAccess(plugin string) bool { + for _, p := range u.Permissions.Plugins { + if p == plugin || p == "*" { + return true + } + } + + return false +} + +// HasClusterAccess checks if the user has access to the given cluster. +func (u *User) HasClusterAccess(cluster string) bool { + for _, resource := range u.Permissions.Resources { + for _, c := range resource.Clusters { + if c == cluster || c == "*" { + return true + } + } + } + + return false +} + +// HasNamespaceAccess checks if the user has access to the given namespace in the given cluster. +func (u *User) HasNamespaceAccess(cluster, namespace string) bool { + for _, resource := range u.Permissions.Resources { + for _, c := range resource.Clusters { + if c == cluster || c == "*" { + for _, n := range resource.Namespaces { + if n == namespace || n == "*" { + return true + } + } + } + } + } + + return false +} + +// HasResourceAccess checks if the user has access to the given resource in the given cluster and namespace. +func (u *User) HasResourceAccess(cluster, namespace, name string) bool { + for _, resource := range u.Permissions.Resources { + for _, c := range resource.Clusters { + if c == cluster || c == "*" { + for _, n := range resource.Namespaces { + if n == namespace || n == "*" { + for _, r := range resource.Resources { + if r == name || r == "*" { + return true + } + } + } + } + } + } + } + + return false +} + +// GetUser returns a user from the given context if one is present. Returns the empty string if a user can not be found. +func GetUser(ctx context.Context) (*User, error) { + if ctx == nil { + return nil, fmt.Errorf("Unauthorized") + } + + if user, ok := ctx.Value(UserKey).(User); ok { + return &user, nil + } + + return nil, fmt.Errorf("Unauthorized") +} diff --git a/pkg/api/middleware/auth/context/context_test.go b/pkg/api/middleware/auth/context/context_test.go new file mode 100644 index 000000000..ec1539fde --- /dev/null +++ b/pkg/api/middleware/auth/context/context_test.go @@ -0,0 +1,128 @@ +package context + +import ( + "context" + "testing" + + team "github.com/kobsio/kobs/pkg/api/apis/team/v1beta1" + + "github.com/stretchr/testify/require" +) + +func TestHasPluginAccess(t *testing.T) { + for _, tc := range []struct { + user User + expectedHasAccess bool + }{ + {user: User{ID: "user1", Permissions: team.Permissions{Plugins: []string{"*"}}}, expectedHasAccess: true}, + {user: User{ID: "user2", Permissions: team.Permissions{Plugins: []string{"plugin1"}}}, expectedHasAccess: true}, + {user: User{ID: "user3", Permissions: team.Permissions{Plugins: []string{"plugin1", "plugin2"}}}, expectedHasAccess: true}, + {user: User{ID: "user4", Permissions: team.Permissions{Plugins: []string{"plugin2"}}}, expectedHasAccess: false}, + {user: User{ID: "user5", Permissions: team.Permissions{Plugins: []string{"plugin2", "*"}}}, expectedHasAccess: true}, + } { + t.Run(tc.user.ID, func(t *testing.T) { + actualHasAccess := tc.user.HasPluginAccess("plugin1") + require.Equal(t, tc.expectedHasAccess, actualHasAccess) + }) + } +} + +func TestHasClusterAccess(t *testing.T) { + for _, tc := range []struct { + user User + expectedHasAccess bool + }{ + {user: User{ID: "user1", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"*"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user2", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user3", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster1", "cluster2"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user4", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}}}}}, expectedHasAccess: false}, + {user: User{ID: "user5", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2", "*"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user6", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}}, {Clusters: []string{"*"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user7", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}}, {Clusters: []string{"cluster1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user8", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}}, {Clusters: []string{"cluster3"}}}}}, expectedHasAccess: false}, + } { + t.Run(tc.user.ID, func(t *testing.T) { + actualHasAccess := tc.user.HasClusterAccess("cluster1") + require.Equal(t, tc.expectedHasAccess, actualHasAccess) + }) + } +} + +func TestHasNamespaceAccess(t *testing.T) { + for _, tc := range []struct { + user User + expectedHasAccess bool + }{ + {user: User{ID: "user1", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"*"}, Namespaces: []string{"*"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user2", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"*"}, Namespaces: []string{"namespace1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user3", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"*"}, Namespaces: []string{"namespace2"}}}}}, expectedHasAccess: false}, + + {user: User{ID: "user4", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster1"}, Namespaces: []string{"*"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user5", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster1"}, Namespaces: []string{"namespace1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user6", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster1"}, Namespaces: []string{"namespace2"}}}}}, expectedHasAccess: false}, + + {user: User{ID: "user7", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}, Namespaces: []string{"*"}}}}}, expectedHasAccess: false}, + {user: User{ID: "user8", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}, Namespaces: []string{"namespace1"}}}}}, expectedHasAccess: false}, + {user: User{ID: "user9", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}, Namespaces: []string{"namespace2"}}}}}, expectedHasAccess: false}, + + {user: User{ID: "user10", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}, Namespaces: []string{"*"}}, {Clusters: []string{"cluster1"}, Namespaces: []string{"*"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user11", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}, Namespaces: []string{"*"}}, {Clusters: []string{"cluster1"}, Namespaces: []string{"namespace1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user12", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}, Namespaces: []string{"*"}}, {Clusters: []string{"cluster1"}, Namespaces: []string{"namespace2"}}}}}, expectedHasAccess: false}, + } { + t.Run(tc.user.ID, func(t *testing.T) { + actualHasAccess := tc.user.HasNamespaceAccess("cluster1", "namespace1") + require.Equal(t, tc.expectedHasAccess, actualHasAccess) + }) + } +} + +func TestHasResourceAccess(t *testing.T) { + for _, tc := range []struct { + user User + expectedHasAccess bool + }{ + {user: User{ID: "user1", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"*"}, Namespaces: []string{"*"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user2", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"*"}, Namespaces: []string{"namespace1"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user3", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"*"}, Namespaces: []string{"namespace2"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: false}, + {user: User{ID: "user2", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"*"}, Namespaces: []string{"namespace1"}, Resources: []string{"resource2"}}}}}, expectedHasAccess: false}, + + {user: User{ID: "user4", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster1"}, Namespaces: []string{"*"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user5", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster1"}, Namespaces: []string{"namespace1"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user6", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster1"}, Namespaces: []string{"namespace2"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: false}, + {user: User{ID: "user6", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster1"}, Namespaces: []string{"namespace1"}, Resources: []string{"resource2"}}}}}, expectedHasAccess: false}, + + {user: User{ID: "user7", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}, Namespaces: []string{"*"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: false}, + {user: User{ID: "user8", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}, Namespaces: []string{"namespace1"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: false}, + {user: User{ID: "user9", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}, Namespaces: []string{"namespace2"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: false}, + + {user: User{ID: "user10", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}, Namespaces: []string{"*"}, Resources: []string{"resource1"}}, {Clusters: []string{"cluster1"}, Namespaces: []string{"*"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user11", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}, Namespaces: []string{"*"}, Resources: []string{"resource1"}}, {Clusters: []string{"cluster1"}, Namespaces: []string{"namespace1"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user12", Permissions: team.Permissions{Resources: []team.PermissionsResources{{Clusters: []string{"cluster2"}, Namespaces: []string{"*"}, Resources: []string{"resource1"}}, {Clusters: []string{"cluster1"}, Namespaces: []string{"namespace2"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: false}, + } { + t.Run(tc.user.ID, func(t *testing.T) { + actualHasAccess := tc.user.HasResourceAccess("cluster1", "namespace1", "resource1") + require.Equal(t, tc.expectedHasAccess, actualHasAccess) + }) + } +} + +func TestGetUser(t *testing.T) { + for _, tc := range []struct { + test string + ctx context.Context + isError bool + }{ + {test: "test1", ctx: nil, isError: true}, + {test: "test2", ctx: context.WithValue(context.Background(), UserKey, User{}), isError: false}, + {test: "test3", ctx: context.WithValue(context.Background(), UserKey, ""), isError: true}, + } { + t.Run(tc.test, func(t *testing.T) { + _, err := GetUser(tc.ctx) + if tc.isError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/api/middleware/auth/handler.go b/pkg/api/middleware/auth/handler.go new file mode 100644 index 000000000..70e25d57b --- /dev/null +++ b/pkg/api/middleware/auth/handler.go @@ -0,0 +1,67 @@ +package auth + +import ( + "net/http" + "os" + "time" + + "github.com/go-chi/render" + "github.com/kobsio/kobs/pkg/api/clusters" + authContext "github.com/kobsio/kobs/pkg/api/middleware/auth/context" + "github.com/kobsio/kobs/pkg/api/middleware/errresponse" + + "github.com/sirupsen/logrus" + flag "github.com/spf13/pflag" +) + +var ( + log = logrus.WithFields(logrus.Fields{"package": "authentication"}) + + flagEnabled bool + flagUserHeader string + flagInterval time.Duration + flagDefaultTeam string +) + +func init() { + defaultHeader := "X-Auth-Request-Email" + if os.Getenv("KOBS_API_AUTH_HEADER") != "" { + defaultHeader = os.Getenv("KOBS_API_AUTH_HEADER") + } + + defaultInterval := time.Duration(1 * time.Hour) + if os.Getenv("KOBS_API_AUTH_INTERVAL") != "" { + parsedDefaultInterval, err := time.ParseDuration(os.Getenv("KOBS_API_AUTH_INTERVAL")) + if err == nil && parsedDefaultInterval > 60*time.Second { + defaultInterval = parsedDefaultInterval + } + } + + defaultTeam := "" + if os.Getenv("KOBS_API_AUTH_DEFAULT_TEAM") != "" { + defaultTeam = os.Getenv("KOBS_API_AUTH_DEFAULT_TEAM") + } + + flag.BoolVar(&flagEnabled, "api.auth.enabled", false, "Enable the authentication and authorization middleware.") + flag.StringVar(&flagUserHeader, "api.auth.header", defaultHeader, "The header, which contains the details about the authenticated user.") + flag.StringVar(&flagDefaultTeam, "api.auth.default-team", defaultTeam, "The name of the team, which should be used for a users permissions when a user hasn't any teams. The team is specified in the following format: \"cluster,namespace,name\"") + flag.DurationVar(&flagInterval, "api.auth.interval", defaultInterval, "The interval to refresh the internal users list and there permissions.") +} + +// Handler creates a new Auth handler with passed options. +func Handler(clusters *clusters.Clusters) func(next http.Handler) http.Handler { + a := New(flagEnabled, flagUserHeader, flagDefaultTeam, flagInterval, clusters) + go a.GetPermissions() + return a.Handler +} + +// UserHandler returns the information of the authenticated user. +func UserHandler(w http.ResponseWriter, r *http.Request) { + user, err := authContext.GetUser(r.Context()) + if err != nil { + errresponse.Render(w, r, err, http.StatusUnauthorized, "You are not authorized to access the resource") + return + } + + render.JSON(w, r, user) +} diff --git a/pkg/api/middleware/httplog/httplog.go b/pkg/api/middleware/httplog/httplog.go index 0369f8d27..2c773f861 100644 --- a/pkg/api/middleware/httplog/httplog.go +++ b/pkg/api/middleware/httplog/httplog.go @@ -7,7 +7,7 @@ import ( "net/http" "time" - "github.com/kobsio/kobs/pkg/api/middleware/auth" + authContext "github.com/kobsio/kobs/pkg/api/middleware/auth/context" "github.com/go-chi/chi/v5/middleware" "github.com/sirupsen/logrus" @@ -36,8 +36,8 @@ func (l *StructuredLogger) NewLogEntry(r *http.Request) middleware.LogEntry { logFields["req_id"] = reqID } - if user := auth.GetUser(r.Context()); user != "" { - logFields["user"] = user + if user, _ := authContext.GetUser(r.Context()); user != nil { + logFields["user"] = user.ID } scheme := "http" diff --git a/plugins/core/package.json b/plugins/core/package.json index 6af1f3ada..eedd2c103 100644 --- a/plugins/core/package.json +++ b/plugins/core/package.json @@ -21,6 +21,7 @@ "@types/react-dom": "^17.0.0", "@types/react-router-dom": "^5.1.7", "jsonpath-plus": "^6.0.1", + "md5": "^2.3.0", "react": "^17.0.2", "react-ace": "^9.4.1", "react-dom": "^17.0.2", diff --git a/plugins/core/src/assets/teamsIcon.png b/plugins/core/src/assets/teamsIcon.png new file mode 100644 index 000000000..abce9faaf Binary files /dev/null and b/plugins/core/src/assets/teamsIcon.png differ diff --git a/plugins/core/src/components/app/Account.tsx b/plugins/core/src/components/app/Account.tsx new file mode 100644 index 000000000..3d14e36e1 --- /dev/null +++ b/plugins/core/src/components/app/Account.tsx @@ -0,0 +1,34 @@ +import { Avatar, Card, CardBody, PageSection, PageSectionVariants } from '@patternfly/react-core'; +import React, { useContext } from 'react'; + +import { AuthContext, IAuthContext } from '../../context/AuthContext'; +import AccountTeams from './AccountTeams'; +import { getGravatarImageUrl } from '../../utils/gravatar'; + +const Account: React.FunctionComponent = () => { + const authContext = useContext(AuthContext); + + return ( + + + + +
+ +
{authContext.user.profile.fullName}
+
{authContext.user.profile.position}
+
+
+
+
+ + {authContext.user.profile.teams && } +
+ ); +}; + +export default Account; diff --git a/plugins/core/src/components/app/AccountTeams.tsx b/plugins/core/src/components/app/AccountTeams.tsx new file mode 100644 index 000000000..3252d5ad5 --- /dev/null +++ b/plugins/core/src/components/app/AccountTeams.tsx @@ -0,0 +1,60 @@ +import { Gallery, GalleryItem, PageSection, PageSectionVariants } from '@patternfly/react-core'; +import React from 'react'; +import { useQuery } from 'react-query'; + +import { IAuthProfile, IAuthProfileTeam } from '../../context/AuthContext'; +import AccountTeamsItem from './AccountTeamsItem'; + +export interface IAccountTeamsProps { + user: IAuthProfile; +} + +const AccountTeams: React.FunctionComponent = ({ user }: IAccountTeamsProps) => { + const { isError, isLoading, data } = useQuery(['users/teams', user], async () => { + try { + const response = await fetch(`/api/plugins/users/teams?cluster=${user.cluster}&namespace=${user.namespace}`, { + body: JSON.stringify({ + teams: user.teams, + }), + method: 'post', + }); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return json; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }); + + if (isLoading || isError || !data) { + return null; + } + + return ( + + + {data.map((team, index) => ( + + + + ))} + + + ); +}; + +export default AccountTeams; diff --git a/plugins/core/src/components/app/AccountTeamsItem.tsx b/plugins/core/src/components/app/AccountTeamsItem.tsx new file mode 100644 index 000000000..7f7f6f130 --- /dev/null +++ b/plugins/core/src/components/app/AccountTeamsItem.tsx @@ -0,0 +1,41 @@ +import { Avatar, Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core'; +import React from 'react'; + +import { LinkWrapper } from '../misc/LinkWrapper'; +import teamsIcon from '../../assets/teamsIcon.png'; + +interface IAccountTeamsItemProps { + cluster: string; + namespace: string; + name: string; + description?: string; + logo?: string; +} + +const AccountTeamsItem: React.FunctionComponent = ({ + cluster, + namespace, + name, + description, + logo, +}: IAccountTeamsItemProps) => { + let teamLogo = logo; + + if (!teamLogo) { + teamLogo = teamsIcon; + } + + return ( + + + + + {name} + + {description} + + + ); +}; + +export default AccountTeamsItem; diff --git a/plugins/core/src/components/app/App.tsx b/plugins/core/src/components/app/App.tsx index b75a4cf8a..0df238a69 100644 --- a/plugins/core/src/components/app/App.tsx +++ b/plugins/core/src/components/app/App.tsx @@ -8,6 +8,7 @@ import '@patternfly/patternfly/patternfly.css'; import '@patternfly/patternfly/patternfly-addons.css'; import { IPluginComponents, PluginsContextProvider } from '../../context/PluginsContext'; +import { AuthContextProvider } from '../../context/AuthContext'; import { ClustersContextProvider } from '../../context/ClustersContext'; import HomePage from './HomePage'; import { PluginPage } from '../plugin/PluginPage'; @@ -42,20 +43,22 @@ export const App: React.FunctionComponent = ({ plugins }: IAppProps) return ( - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + ); }; diff --git a/plugins/core/src/components/app/HomePage.tsx b/plugins/core/src/components/app/HomePage.tsx index f5e48fa16..1566c4dd0 100644 --- a/plugins/core/src/components/app/HomePage.tsx +++ b/plugins/core/src/components/app/HomePage.tsx @@ -1,6 +1,5 @@ import { - Gallery, - GalleryItem, + Divider, Grid, GridItem, Menu, @@ -13,14 +12,16 @@ import { import React, { useContext, useEffect, useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; +import { AuthContext, IAuthContext } from '../../context/AuthContext'; import { IPluginsContext, PluginsContext } from '../../context/PluginsContext'; -import HomeItem from './HomeItem'; +import Account from './Account'; +import Plugins from './Plugins'; -// HomePage renders a list of all registered plugin instances via the HomeItem component. const HomePage: React.FunctionComponent = () => { const history = useHistory(); const location = useLocation(); + const authContext = useContext(AuthContext); const pluginsContext = useContext(PluginsContext); const pluginHome = pluginsContext.getPluginHome(); const [activePage, setActivePage] = useState('plugins'); @@ -65,26 +66,28 @@ const HomePage: React.FunctionComponent = () => { {plugin.displayName} ))} + {authContext.user.hasProfile && ( + + + My Account + + )} - {activePage !== 'plugins' && pluginDetails && Component ? ( + {activePage !== 'plugins' && activePage !== 'account' && pluginDetails && Component ? ( + ) : activePage === 'account' ? ( + ) : ( - - {pluginsContext.plugins.map((plugin) => ( - - - - ))} - + )} diff --git a/plugins/core/src/components/app/HomeItem.tsx b/plugins/core/src/components/app/PluginItem.tsx similarity index 70% rename from plugins/core/src/components/app/HomeItem.tsx rename to plugins/core/src/components/app/PluginItem.tsx index a31096d09..231851c85 100644 --- a/plugins/core/src/components/app/HomeItem.tsx +++ b/plugins/core/src/components/app/PluginItem.tsx @@ -4,14 +4,14 @@ import React, { useContext } from 'react'; import { IPluginData, IPluginsContext, PluginsContext } from '../../context/PluginsContext'; import { LinkWrapper } from '../misc/LinkWrapper'; -// IHomeItemProps is the interface for an item on the home page. Each item contains a title, body, link and icon. -interface IHomeItemProps { +// IPluginItemProps is the interface for an item on the home page. Each item contains a title, body, link and icon. +interface IPluginItemProps { plugin: IPluginData; } -// HomeItem is used to render an item in the home page. It requires a title, body, link and icon. When the card is +// PluginItem is used to render an item in the home page. It requires a title, body, link and icon. When the card is // clicked, the user is redirected to the provided link. -const HomeItem: React.FunctionComponent = ({ plugin }: IHomeItemProps) => { +const PluginItem: React.FunctionComponent = ({ plugin }: IPluginItemProps) => { const pluginsContext = useContext(PluginsContext); return ( @@ -32,4 +32,4 @@ const HomeItem: React.FunctionComponent = ({ plugin }: IHomeItem ); }; -export default HomeItem; +export default PluginItem; diff --git a/plugins/core/src/components/app/Plugins.tsx b/plugins/core/src/components/app/Plugins.tsx new file mode 100644 index 000000000..029b18c5a --- /dev/null +++ b/plugins/core/src/components/app/Plugins.tsx @@ -0,0 +1,29 @@ +import { Gallery, GalleryItem } from '@patternfly/react-core'; +import React, { useContext } from 'react'; + +import { AuthContext, IAuthContext } from '../../context/AuthContext'; +import { IPluginData } from '../../context/PluginsContext'; +import PluginItem from './PluginItem'; + +export interface IPluginsProps { + plugins: IPluginData[]; +} + +const Plugins: React.FunctionComponent = ({ plugins }: IPluginsProps) => { + const authContext = useContext(AuthContext); + + return ( + + {plugins.map( + (plugin) => + authContext.hasPluginAccess(plugin.name) && ( + + + + ), + )} + + ); +}; + +export default Plugins; diff --git a/plugins/core/src/components/plugin/PluginPage.tsx b/plugins/core/src/components/plugin/PluginPage.tsx index e294511a1..9c042adc3 100644 --- a/plugins/core/src/components/plugin/PluginPage.tsx +++ b/plugins/core/src/components/plugin/PluginPage.tsx @@ -2,6 +2,7 @@ import { Alert, AlertActionLink, AlertVariant, PageSection } from '@patternfly/r import React, { useContext } from 'react'; import { useHistory, useParams } from 'react-router-dom'; +import { AuthContext, IAuthContext } from '../../context/AuthContext'; import { IPluginsContext, PluginsContext } from '../../context/PluginsContext'; // IPluginParams are the parameters for the PluginPage component. For the PluginPage we only require the name of the @@ -16,6 +17,9 @@ interface IPluginParams { export const PluginPage: React.FunctionComponent = () => { const history = useHistory(); const params = useParams(); + + const authContext = useContext(AuthContext); + const pluginsContext = useContext(PluginsContext); const pluginDetails = pluginsContext.getPluginDetails(params.name); const Component = @@ -23,7 +27,7 @@ export const PluginPage: React.FunctionComponent = () => { ? pluginsContext.components[pluginDetails.type].page : undefined; - if (!pluginDetails) { + if (!pluginDetails || !authContext.hasPluginAccess(pluginDetails.name)) { return ( = ({ options, showDetails, }: IPluginPanelProps) => { + const authContext = useContext(AuthContext); + const pluginsContext = useContext(PluginsContext); const pluginDetails = pluginsContext.getPluginDetails(name); const Component = @@ -25,7 +28,7 @@ export const PluginPanel: React.FunctionComponent = ({ ? pluginsContext.components[pluginDetails.type].panel : undefined; - if (!pluginDetails || !Component) { + if (!pluginDetails || !Component || !authContext.hasPluginAccess(pluginDetails.name)) { return ( diff --git a/plugins/core/src/components/plugin/PluginPreview.tsx b/plugins/core/src/components/plugin/PluginPreview.tsx index 2796177e7..1feef4706 100644 --- a/plugins/core/src/components/plugin/PluginPreview.tsx +++ b/plugins/core/src/components/plugin/PluginPreview.tsx @@ -1,6 +1,7 @@ import { Alert, AlertVariant } from '@patternfly/react-core'; import React, { useContext } from 'react'; +import { AuthContext, IAuthContext } from '../../context/AuthContext'; import { IPluginPreviewProps, IPluginsContext, PluginsContext } from '../../context/PluginsContext'; export const PluginPreview: React.FunctionComponent = ({ @@ -9,6 +10,8 @@ export const PluginPreview: React.FunctionComponent = ({ name, options, }: IPluginPreviewProps) => { + const authContext = useContext(AuthContext); + const pluginsContext = useContext(PluginsContext); const pluginDetails = pluginsContext.getPluginDetails(name); const Component = @@ -16,7 +19,7 @@ export const PluginPreview: React.FunctionComponent = ({ ? pluginsContext.components[pluginDetails.type].preview : undefined; - if (!pluginDetails || !Component) { + if (!pluginDetails || !Component || !authContext.hasPluginAccess(pluginDetails.name)) { return ( {pluginDetails ? ( diff --git a/plugins/core/src/context/AuthContext.tsx b/plugins/core/src/context/AuthContext.tsx new file mode 100644 index 000000000..af495f6e3 --- /dev/null +++ b/plugins/core/src/context/AuthContext.tsx @@ -0,0 +1,165 @@ +import { Alert, AlertActionLink, AlertVariant, Spinner } from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; + +export interface IAuth { + id: string; + hasProfile: boolean; + profile: IAuthProfile; + permissions: IAuthPermissions; +} + +export interface IAuthProfile { + cluster: string; + namespace: string; + name: string; + id: string; + fullName: string; + email: string; + position?: string; + bio?: string; + teams?: IAuthProfileTeamReference[]; +} + +export interface IAuthProfileTeamReference { + cluster?: string; + namespace?: string; + name: string; +} + +export interface IAuthProfileTeam { + cluster: string; + namespace: string; + name: string; + description?: string; + logo?: string; +} + +export interface IAuthPermissions { + plugins: string[]; + resources: IAuthPermissionsResources[]; +} + +export interface IAuthPermissionsResources { + clusters: string[]; + namespaces: string[]; + resources: string[]; +} + +// IAuthContext is the plugin context, is contains all plugins. +export interface IAuthContext { + user: IAuth; + hasPluginAccess: (name: string) => boolean; +} + +// AuthContext is the plugin context object. +export const AuthContext = React.createContext({ + hasPluginAccess: (name: string) => { + return false; + }, + user: { + hasProfile: false, + id: '', + permissions: { + plugins: [], + resources: [], + }, + profile: { + cluster: '', + email: '', + fullName: '', + id: '', + name: '', + namespace: '', + }, + }, +}); + +// AuthContextConsumer is a React component that subscribes to context changes. This lets you subscribe to a context +// within a function component. +export const AuthContextConsumer = AuthContext.Consumer; + +// IAuthContextProviderProps is the interface for the AuthContextProvider component. The only valid properties are +// child components of the type ReactElement. +interface IAuthContextProviderProps { + children: React.ReactElement; +} + +// AuthContextProvider is a Provider React component that allows consuming components to subscribe to context +// changes. +export const AuthContextProvider: React.FunctionComponent = ({ + children, +}: IAuthContextProviderProps) => { + const { isError, isLoading, error, data, refetch } = useQuery(['shared/auth'], async () => { + try { + const response = await fetch('/api/user', { method: 'get' }); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return json; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }); + + const hasPluginAccess = (name: string): boolean => { + if (data) { + for (const plugin of data?.permissions.plugins) { + if (plugin === name || plugin === '*') { + return true; + } + } + } + + return false; + }; + + // As long as the isLoading property of the state is true, we are showing a spinner in the cernter of the screen. + if (isLoading) { + return ; + } + + // If an error occured during the fetch of the plugins, we are showing the error message in the cernter of the screen + // within an Alert component. The Alert component contains a Retry button to call the fetchData function again. + if (isError) { + return ( + + > => refetch()}> + Retry + + + } + > +

{error?.message}

+
+ ); + } + + if (!data) { + return null; + } + + // If the fetching of the auth context is finished and was successful, we render the context provider. + return ( + + {children} + + ); +}; diff --git a/plugins/core/src/index.ts b/plugins/core/src/index.ts index 64cf81bee..033dee46b 100644 --- a/plugins/core/src/index.ts +++ b/plugins/core/src/index.ts @@ -16,12 +16,14 @@ export * from './components/plugin/PluginOptionsMissing'; export * from './components/plugin/PluginPanel'; export * from './components/plugin/PluginPreview'; +export * from './context/AuthContext'; export * from './context/ClustersContext'; export * from './context/PluginsContext'; export * from './context/TerminalsContext'; export * from './utils/chart'; export * from './utils/colors'; +export * from './utils/gravatar'; export * from './utils/manifests'; export * from './utils/resources'; export * from './utils/time'; diff --git a/plugins/users/src/utils/helpers.ts b/plugins/core/src/utils/gravatar.ts similarity index 100% rename from plugins/users/src/utils/helpers.ts rename to plugins/core/src/utils/gravatar.ts diff --git a/plugins/opsgenie/opsgenie.go b/plugins/opsgenie/opsgenie.go index 19c0144c1..05b28d5fa 100644 --- a/plugins/opsgenie/opsgenie.go +++ b/plugins/opsgenie/opsgenie.go @@ -5,7 +5,7 @@ import ( "time" "github.com/kobsio/kobs/pkg/api/clusters" - "github.com/kobsio/kobs/pkg/api/middleware/auth" + authContext "github.com/kobsio/kobs/pkg/api/middleware/auth/context" "github.com/kobsio/kobs/pkg/api/middleware/errresponse" "github.com/kobsio/kobs/pkg/api/plugins/plugin" "github.com/kobsio/kobs/plugins/opsgenie/pkg/instance" @@ -17,8 +17,7 @@ import ( // Route is the route under which the plugin should be registered in our router for the rest api. const ( - Route = "/opsgenie" - DefaultUser = "kobs.io" + Route = "/opsgenie" ) var ( @@ -214,6 +213,12 @@ func (router *Router) getIncidentTimeline(w http.ResponseWriter, r *http.Request } func (router *Router) acknowledgeAlert(w http.ResponseWriter, r *http.Request) { + user, err := authContext.GetUser(r.Context()) + if err != nil { + errresponse.Render(w, r, err, http.StatusUnauthorized, "You are not authorized to acknowledge the alert") + return + } + name := chi.URLParam(r, "name") id := r.URL.Query().Get("id") @@ -230,12 +235,7 @@ func (router *Router) acknowledgeAlert(w http.ResponseWriter, r *http.Request) { return } - user := auth.GetUser(r.Context()) - if user == "" { - user = DefaultUser - } - - err := i.AcknowledgeAlert(r.Context(), id, user) + err = i.AcknowledgeAlert(r.Context(), id, user.ID) if err != nil { errresponse.Render(w, r, err, http.StatusInternalServerError, "Could not acknowledge alert") return @@ -245,6 +245,12 @@ func (router *Router) acknowledgeAlert(w http.ResponseWriter, r *http.Request) { } func (router *Router) snoozeAlert(w http.ResponseWriter, r *http.Request) { + user, err := authContext.GetUser(r.Context()) + if err != nil { + errresponse.Render(w, r, err, http.StatusUnauthorized, "You are not authorized to snooze the alert") + return + } + name := chi.URLParam(r, "name") id := r.URL.Query().Get("id") snooze := r.URL.Query().Get("snooze") @@ -268,12 +274,7 @@ func (router *Router) snoozeAlert(w http.ResponseWriter, r *http.Request) { return } - user := auth.GetUser(r.Context()) - if user == "" { - user = DefaultUser - } - - err = i.SnoozeAlert(r.Context(), id, user, snoozeParsed) + err = i.SnoozeAlert(r.Context(), id, user.ID, snoozeParsed) if err != nil { errresponse.Render(w, r, err, http.StatusInternalServerError, "Could not snooze alert") return @@ -283,6 +284,12 @@ func (router *Router) snoozeAlert(w http.ResponseWriter, r *http.Request) { } func (router *Router) closeAlert(w http.ResponseWriter, r *http.Request) { + user, err := authContext.GetUser(r.Context()) + if err != nil { + errresponse.Render(w, r, err, http.StatusUnauthorized, "You are not authorized to close the alert") + return + } + name := chi.URLParam(r, "name") id := r.URL.Query().Get("id") @@ -299,12 +306,7 @@ func (router *Router) closeAlert(w http.ResponseWriter, r *http.Request) { return } - user := auth.GetUser(r.Context()) - if user == "" { - user = DefaultUser - } - - err := i.CloseAlert(r.Context(), id, user) + err = i.CloseAlert(r.Context(), id, user.ID) if err != nil { errresponse.Render(w, r, err, http.StatusInternalServerError, "Could not close alert") return diff --git a/plugins/resources/resources.go b/plugins/resources/resources.go index 2476e55ce..169d7fe02 100644 --- a/plugins/resources/resources.go +++ b/plugins/resources/resources.go @@ -10,6 +10,7 @@ import ( "github.com/kobsio/kobs/pkg/api/clusters" "github.com/kobsio/kobs/pkg/api/clusters/cluster/terminal" + authContext "github.com/kobsio/kobs/pkg/api/middleware/auth/context" "github.com/kobsio/kobs/pkg/api/middleware/errresponse" "github.com/kobsio/kobs/pkg/api/plugins/plugin" @@ -72,6 +73,12 @@ func (router *Router) isForbidden(resource string) bool { // getResources returns a list of resources for the given clusters and namespaces. The result can limited by the // paramName and param query parameter. func (router *Router) getResources(w http.ResponseWriter, r *http.Request) { + user, err := authContext.GetUser(r.Context()) + if err != nil { + errresponse.Render(w, r, err, http.StatusUnauthorized, "You are not authorized to access the resource") + return + } + clusterNames := r.URL.Query()["cluster"] namespaces := r.URL.Query()["namespace"] name := r.URL.Query().Get("name") @@ -102,6 +109,11 @@ func (router *Router) getResources(w http.ResponseWriter, r *http.Request) { // provided we loop through all the namespaces and return the resources for these namespaces. All results are // added to the resources slice, which is then returned by the api. if namespaces == nil { + if !user.HasResourceAccess(clusterName, "*", resource) { + errresponse.Render(w, r, fmt.Errorf("cluster: %s, namespace: *, resource: %s", clusterName, resource), http.StatusForbidden, "You are not authorized to access the resource") + return + } + list, err := cluster.GetResources(r.Context(), "", name, path, resource, paramName, param) if err != nil { errresponse.Render(w, r, err, http.StatusBadRequest, "Could not get resources") @@ -122,6 +134,11 @@ func (router *Router) getResources(w http.ResponseWriter, r *http.Request) { }) } else { for _, namespace := range namespaces { + if !user.HasResourceAccess(clusterName, namespace, resource) { + errresponse.Render(w, r, fmt.Errorf("cluster: %s, namespace: %s, resource: %s", clusterName, namespace, resource), http.StatusForbidden, "You are not authorized to access the resource") + return + } + list, err := cluster.GetResources(r.Context(), namespace, name, path, resource, paramName, param) if err != nil { errresponse.Render(w, r, err, http.StatusBadRequest, "Could not get resources") @@ -153,6 +170,12 @@ func (router *Router) getResources(w http.ResponseWriter, r *http.Request) { // When the user sets the "force" parameter to "true" we will set a body on the delete request, where we set the // "gracePeriodSeconds" to 0. This will cause the same behaviour as "kubectl delete --force --grace-period 0". func (router *Router) deleteResource(w http.ResponseWriter, r *http.Request) { + user, err := authContext.GetUser(r.Context()) + if err != nil { + errresponse.Render(w, r, err, http.StatusUnauthorized, "You are not authorized to access the resource") + return + } + clusterName := r.URL.Query().Get("cluster") namespace := r.URL.Query().Get("namespace") name := r.URL.Query().Get("name") @@ -162,6 +185,11 @@ func (router *Router) deleteResource(w http.ResponseWriter, r *http.Request) { log.WithFields(logrus.Fields{"cluster": clusterName, "namespace": namespace, "name": name, "resource": resource, "path": path}).Tracef("deleteResource") + if !user.HasResourceAccess(clusterName, namespace, resource) { + errresponse.Render(w, r, fmt.Errorf("cluster: %s, namespace: %s, resource: %s", clusterName, namespace, resource), http.StatusForbidden, "You are not authorized to access the resource") + return + } + cluster := router.clusters.GetCluster(clusterName) if cluster == nil { errresponse.Render(w, r, nil, http.StatusBadRequest, "Invalid cluster name") @@ -196,6 +224,12 @@ func (router *Router) deleteResource(w http.ResponseWriter, r *http.Request) { // patchResource hadnles patch operations for resources. The resource can be identified by the given cluster, // namespace, name, resource and path. The patch operation must be provided in the request body. func (router *Router) patchResource(w http.ResponseWriter, r *http.Request) { + user, err := authContext.GetUser(r.Context()) + if err != nil { + errresponse.Render(w, r, err, http.StatusUnauthorized, "You are not authorized to access the resource") + return + } + clusterName := r.URL.Query().Get("cluster") namespace := r.URL.Query().Get("namespace") name := r.URL.Query().Get("name") @@ -204,6 +238,11 @@ func (router *Router) patchResource(w http.ResponseWriter, r *http.Request) { log.WithFields(logrus.Fields{"cluster": clusterName, "namespace": namespace, "name": name, "resource": resource, "path": path}).Tracef("patchResource") + if !user.HasResourceAccess(clusterName, namespace, resource) { + errresponse.Render(w, r, fmt.Errorf("cluster: %s, namespace: %s, resource: %s", clusterName, namespace, resource), http.StatusForbidden, "You are not authorized to access the resource") + return + } + cluster := router.clusters.GetCluster(clusterName) if cluster == nil { errresponse.Render(w, r, nil, http.StatusBadRequest, "Invalid cluster name") @@ -233,6 +272,12 @@ func (router *Router) patchResource(w http.ResponseWriter, r *http.Request) { // createResource hadnles patch operations for resources. The resource can be identified by the given cluster, // namespace, name, resource and path. The resource must be provided in the request body. func (router *Router) createResource(w http.ResponseWriter, r *http.Request) { + user, err := authContext.GetUser(r.Context()) + if err != nil { + errresponse.Render(w, r, err, http.StatusUnauthorized, "You are not authorized to access the resource") + return + } + clusterName := r.URL.Query().Get("cluster") namespace := r.URL.Query().Get("namespace") name := r.URL.Query().Get("name") @@ -242,6 +287,11 @@ func (router *Router) createResource(w http.ResponseWriter, r *http.Request) { log.WithFields(logrus.Fields{"cluster": clusterName, "namespace": namespace, "name": name, "path": path, "resource": resource, "subResource": subResource}).Tracef("createResource") + if !user.HasResourceAccess(clusterName, namespace, resource) { + errresponse.Render(w, r, fmt.Errorf("cluster: %s, namespace: %s, resource: %s", clusterName, namespace, resource), http.StatusForbidden, "You are not authorized to access the resource") + return + } + cluster := router.clusters.GetCluster(clusterName) if cluster == nil { errresponse.Render(w, r, nil, http.StatusBadRequest, "Invalid cluster name") @@ -345,6 +395,17 @@ func (router *Router) getLogs(w http.ResponseWriter, r *http.Request) { } }() + user, err := authContext.GetUser(r.Context()) + if err != nil { + c.WriteMessage(websocket.TextMessage, []byte("You are not authorized to access the resource")) + return + } + + if !user.HasResourceAccess(clusterName, namespace, "pods") { + c.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("You are not authorized to access the resource: cluster: %s, namespace: %s, resource: pods", clusterName, namespace))) + return + } + err = cluster.StreamLogs(r.Context(), c, namespace, name, container, parsedSince, parsedTail, parsedFollow) if err != nil { c.WriteMessage(websocket.TextMessage, []byte("Could not stream logs: "+err.Error())) @@ -355,6 +416,17 @@ func (router *Router) getLogs(w http.ResponseWriter, r *http.Request) { return } + user, err := authContext.GetUser(r.Context()) + if err != nil { + errresponse.Render(w, r, err, http.StatusUnauthorized, "You are not authorized to access the resource") + return + } + + if !user.HasResourceAccess(clusterName, namespace, "pods") { + errresponse.Render(w, r, fmt.Errorf("cluster: %s, namespace: %s, resource: pods", clusterName, namespace), http.StatusForbidden, "You are not authorized to access the resource") + return + } + logs, err := cluster.GetLogs(r.Context(), namespace, name, container, regex, parsedSince, parsedTail, parsedPrevious) if err != nil { errresponse.Render(w, r, err, http.StatusBadGateway, "Could not get logs") @@ -408,6 +480,27 @@ func (router *Router) getTerminal(w http.ResponseWriter, r *http.Request) { } }() + user, err := authContext.GetUser(r.Context()) + if err != nil { + msg, _ := json.Marshal(terminal.Message{ + Op: "stdout", + Data: "You are not authorized to access the resource", + }) + + c.WriteMessage(websocket.TextMessage, msg) + return + } + + if !user.HasResourceAccess(clusterName, namespace, "pods") { + msg, _ := json.Marshal(terminal.Message{ + Op: "stdout", + Data: fmt.Sprintf("You are not authorized to access the resource: cluster: %s, namespace: %s, resource: pods", clusterName, namespace), + }) + + c.WriteMessage(websocket.TextMessage, msg) + return + } + cluster := router.clusters.GetCluster(clusterName) if cluster == nil { log.WithError(err).Errorf("Invalid cluster name") diff --git a/plugins/resources/src/components/panel/details/actions/Logs.tsx b/plugins/resources/src/components/panel/details/actions/Logs.tsx index 37cde11ca..085c6725b 100644 --- a/plugins/resources/src/components/panel/details/actions/Logs.tsx +++ b/plugins/resources/src/components/panel/details/actions/Logs.tsx @@ -130,7 +130,7 @@ const Logs: React.FunctionComponent = ({ request, resource, show, se resource.namespace ? `&namespace=${resource.namespace.title}` : '' }&name=${resource.name.title}&container=${container}®ex=${encodeURIComponent(regex)}&since=${since}&tail=${ TERMINAL_OPTIONS.scrollback - }&previous=${previous}&follow=true`, + }&previous=${previous}&follow=false`, { method: 'get' }, ); const json = await response.json(); diff --git a/plugins/users/package.json b/plugins/users/package.json index 2ffaa1ed3..ec33d617f 100644 --- a/plugins/users/package.json +++ b/plugins/users/package.json @@ -16,7 +16,6 @@ "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", "@types/react-router-dom": "^5.1.7", - "md5": "^2.3.0", "react": "^17.0.2", "react-dom": "^17.0.2", "react-markdown": "^7.0.1", diff --git a/plugins/users/src/components/home/Home.tsx b/plugins/users/src/components/home/Home.tsx index c788dd66f..a6767e629 100644 --- a/plugins/users/src/components/home/Home.tsx +++ b/plugins/users/src/components/home/Home.tsx @@ -15,15 +15,14 @@ import { import { QueryObserverResult, useQuery } from 'react-query'; import React, { useState } from 'react'; -import { IPluginPageProps, useDebounce } from '@kobsio/plugin-core'; -import { IUser } from '../../utils/interfaces'; +import { IAuthProfile, IPluginPageProps, useDebounce } from '@kobsio/plugin-core'; import UsersItem from '../page/UsersItem'; const Home: React.FunctionComponent = () => { const [searchTerm, setSearchTerm] = useState(''); const debouncedSearchTerm = useDebounce(searchTerm, 500); - const { isError, isLoading, error, data, refetch } = useQuery(['users/users'], async () => { + const { isError, isLoading, error, data, refetch } = useQuery(['users/users'], async () => { try { const response = await fetch(`/api/plugins/users/users`, { method: 'get' }); const json = await response.json(); @@ -57,7 +56,7 @@ const Home: React.FunctionComponent = () => { title="Could not get users" actionLinks={ - > => refetch()}> + > => refetch()}> Retry diff --git a/plugins/users/src/components/page/Teams.tsx b/plugins/users/src/components/page/Teams.tsx index adffc3b6f..b192562be 100644 --- a/plugins/users/src/components/page/Teams.tsx +++ b/plugins/users/src/components/page/Teams.tsx @@ -3,10 +3,10 @@ import React from 'react'; import { useQuery } from 'react-query'; import { ITeamTeam, TeamsItem } from '@kobsio/plugin-teams'; -import { IUser } from '../../utils/interfaces'; +import { IAuthProfile } from '@kobsio/plugin-core'; export interface ITeamsProps { - user: IUser; + user: IAuthProfile; } const Teams: React.FunctionComponent = ({ user }: ITeamsProps) => { diff --git a/plugins/users/src/components/page/User.tsx b/plugins/users/src/components/page/User.tsx index cb2fb9286..d93daaaad 100644 --- a/plugins/users/src/components/page/User.tsx +++ b/plugins/users/src/components/page/User.tsx @@ -15,9 +15,8 @@ import { useHistory, useParams } from 'react-router-dom'; import React from 'react'; import ReactMarkdown from 'react-markdown'; -import { IUser } from '../../utils/interfaces'; +import { IAuthProfile, getGravatarImageUrl } from '@kobsio/plugin-core'; import Teams from './Teams'; -import { getGravatarImageUrl } from '../../utils/helpers'; interface IUserParams { cluster: string; @@ -31,7 +30,7 @@ const User: React.FunctionComponent = () => { const history = useHistory(); const params = useParams(); - const { isError, isLoading, error, data, refetch } = useQuery( + const { isError, isLoading, error, data, refetch } = useQuery( ['users/user', params.cluster, params.namespace, params.name], async () => { try { @@ -73,7 +72,7 @@ const User: React.FunctionComponent = () => { history.push('/')}>Home history.push('/users')}>Users - > => refetch()}> + > => refetch()}> Retry diff --git a/plugins/users/src/components/page/Users.tsx b/plugins/users/src/components/page/Users.tsx index 84afe6727..c4969d374 100644 --- a/plugins/users/src/components/page/Users.tsx +++ b/plugins/users/src/components/page/Users.tsx @@ -16,7 +16,7 @@ import { QueryObserverResult, useQuery } from 'react-query'; import React from 'react'; import { useHistory } from 'react-router-dom'; -import { IUser } from '../../utils/interfaces'; +import { IAuthProfile } from '@kobsio/plugin-core'; import UsersItem from './UsersItem'; export interface IUsersProps { @@ -29,7 +29,7 @@ export interface IUsersProps { const Users: React.FunctionComponent = ({ displayName, description }: IUsersProps) => { const history = useHistory(); - const { isError, isLoading, error, data, refetch } = useQuery(['users/users'], async () => { + const { isError, isLoading, error, data, refetch } = useQuery(['users/users'], async () => { try { const response = await fetch(`/api/plugins/users/users`, { method: 'get' }); const json = await response.json(); @@ -72,7 +72,7 @@ const Users: React.FunctionComponent = ({ displayName, description actionLinks={ history.push('/')}>Home - > => refetch()}> + > => refetch()}> Retry diff --git a/plugins/users/src/components/page/UsersItem.tsx b/plugins/users/src/components/page/UsersItem.tsx index b7148e80f..a133f47a5 100644 --- a/plugins/users/src/components/page/UsersItem.tsx +++ b/plugins/users/src/components/page/UsersItem.tsx @@ -1,8 +1,7 @@ import { Avatar, Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core'; import React from 'react'; -import { LinkWrapper } from '@kobsio/plugin-core'; -import { getGravatarImageUrl } from '../../utils/helpers'; +import { LinkWrapper, getGravatarImageUrl } from '@kobsio/plugin-core'; interface IUsersItemProps { cluster: string; diff --git a/plugins/users/src/components/panel/Users.tsx b/plugins/users/src/components/panel/Users.tsx index ebdf3fc01..9edc951be 100644 --- a/plugins/users/src/components/panel/Users.tsx +++ b/plugins/users/src/components/panel/Users.tsx @@ -2,7 +2,7 @@ import { Alert, AlertActionLink, AlertVariant, Gallery, GalleryItem, Spinner } f import { QueryObserverResult, useQuery } from 'react-query'; import React from 'react'; -import { IUser } from '../../utils/interfaces'; +import { IAuthProfile } from '@kobsio/plugin-core'; import UsersItem from '../page/UsersItem'; interface IUsersProps { @@ -13,7 +13,7 @@ interface IUsersProps { // The Users component is used to load all users for the specified team. const Users: React.FunctionComponent = ({ cluster, namespace, name }: IUsersProps) => { - const { isError, isLoading, error, data, refetch } = useQuery( + const { isError, isLoading, error, data, refetch } = useQuery( ['users/team', cluster, namespace, name], async () => { try { @@ -52,7 +52,7 @@ const Users: React.FunctionComponent = ({ cluster, namespace, name title="Could not get users" actionLinks={ - > => refetch()}> + > => refetch()}> Retry diff --git a/plugins/users/src/utils/interfaces.ts b/plugins/users/src/utils/interfaces.ts index 794909e77..0afd8e9f7 100644 --- a/plugins/users/src/utils/interfaces.ts +++ b/plugins/users/src/utils/interfaces.ts @@ -1,24 +1,3 @@ -// IUser is the interface for a User CR. The interface must implement the same fields as the Users CRD. the only -// different is that we can be sure that the cluster, namespace and name of a user is always present in the frontend, -// because it will be set when a user is retrieved from the Kubernetes API. -export interface IUser { - cluster: string; - namespace: string; - name: string; - id: string; - fullName: string; - email: string; - position?: string; - bio?: string; - teams?: ITeam[]; -} - -export interface ITeam { - cluster?: string; - namespace?: string; - name: string; -} - export interface IPanelOptions { cluster?: string; namespace?: string;