From cf747595c5927e3e45bd79c7b29becadca74de4a Mon Sep 17 00:00:00 2001 From: ricoberger Date: Wed, 22 Dec 2021 22:52:52 +0100 Subject: [PATCH] [core] Rework auth middleware This is a complete rework of the authentication / authorization middleware used in kobs. We do not longer depend on the teams array in a User CR or on a default Team CR to authorize a user. Instead we are only using the values from the provided user and teams header to get the permissions of a user when the auth middleware is enabled. For that we also updated the Custom Resource Definition for Users and Teams. Both CRDs can be used to define the permissions for a user. For that both CRDs now have an "id" field which must be unique across all clusters and namespaces. If a user is part of multiple teams we merge the permissions of all teams for the user. To reduce the number of Kubernetes API calls, we save the user information now in a cookie, which contains a signed JWT token with the users profile and permissions. We automatically try to refresh the JWT token when it is expired. If the token could not be refreshed we return an unauthorized error. When the auth middleware is disabled, we still try to get the user id from the defined user header and inject it into the request context. This way we can simplify the auth handling in some plugins, because we always have a valid user object in the context and the user id can be used for a lightweighted audit logging. --- CHANGELOG.md | 1 + deploy/helm/kobs/Chart.yaml | 2 +- deploy/helm/kobs/crds/kobs.io_teams.yaml | 11 +- deploy/helm/kobs/crds/kobs.io_users.yaml | 63 +++- deploy/helm/kobs/templates/deployment.yaml | 6 +- deploy/helm/kobs/values.yaml | 15 +- deploy/kustomize/crds/kobs.io_teams.yaml | 11 +- deploy/kustomize/crds/kobs.io_users.yaml | 63 +++- docs/configuration/authentication.md | 8 +- docs/configuration/getting-started.md | 9 +- docs/installation/helm.md | 6 +- docs/resources/teams.md | 30 +- docs/resources/users.md | 54 ++- go.mod | 1 + go.sum | 2 + pkg/api/apis/team/v1beta1/types.go | 22 +- .../team/v1beta1/zz_generated.deepcopy.go | 83 ----- pkg/api/apis/user/v1beta1/types.go | 40 ++- .../user/v1beta1/zz_generated.deepcopy.go | 96 ++++++ pkg/api/middleware/auth/auth.go | 308 +++++++++--------- pkg/api/middleware/auth/context/context.go | 20 +- .../middleware/auth/context/context_test.go | 80 ++--- pkg/api/middleware/auth/handler.go | 50 +-- pkg/api/middleware/auth/jwt/jwt.go | 51 +++ plugins/core/src/components/app/Home.tsx | 2 +- .../src/components/app/account/Account.tsx | 12 +- .../components/app/account/AccountTeams.tsx | 53 +-- plugins/core/src/context/AuthContext.tsx | 34 +- plugins/core/src/crds/team.ts | 1 + plugins/core/src/crds/user.ts | 6 +- plugins/users/src/components/home/Home.tsx | 14 +- plugins/users/src/components/page/User.tsx | 12 +- plugins/users/src/components/page/Users.tsx | 4 +- .../users/src/components/page/UsersItem.tsx | 18 +- plugins/users/src/components/panel/Users.tsx | 9 +- 35 files changed, 701 insertions(+), 496 deletions(-) create mode 100644 pkg/api/middleware/auth/jwt/jwt.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 213721dc1..ea66e56ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan - [#239](https://github.com/kobsio/kobs/pull/239): [azure] Cost Management drill-down on resource groups. - [#238](https://github.com/kobsio/kobs/pull/238): [core] Refactor frontend code for plugins (change options handling, use `setDetails` instead of `showDetails` and rename plugins options in panels to `pluginOptions`). - [#240](https://github.com/kobsio/kobs/pull/240): [core] Switch from `github.com/sirupsen/logrus` to `go.uber.org/zap` for logging and enrich log lines via `context.Context`. +- [#241](https://github.com/kobsio/kobs/pull/241): [core] :warning: _Breaking change:_ :warning: Rework authentication / authorization middleware and adjust the Custom Resource Definition for Users and Teams. ## [v0.7.0](https://github.com/kobsio/kobs/releases/tag/v0.7.0) (2021-11-19) diff --git a/deploy/helm/kobs/Chart.yaml b/deploy/helm/kobs/Chart.yaml index 2b36b79ab..5531a8903 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.8.3 +version: 0.8.4 appVersion: v0.7.0 diff --git a/deploy/helm/kobs/crds/kobs.io_teams.yaml b/deploy/helm/kobs/crds/kobs.io_teams.yaml index 2437a7ba0..e8e621100 100644 --- a/deploy/helm/kobs/crds/kobs.io_teams.yaml +++ b/deploy/helm/kobs/crds/kobs.io_teams.yaml @@ -125,6 +125,8 @@ spec: type: array description: type: string + id: + type: string links: items: properties: @@ -145,7 +147,7 @@ spec: type: string permissions: properties: - custom: + plugins: items: properties: name: @@ -154,13 +156,8 @@ spec: x-kubernetes-preserve-unknown-fields: true required: - name - - permissions type: object type: array - plugins: - items: - type: string - type: array resources: items: properties: @@ -186,6 +183,8 @@ spec: - plugins - resources type: object + required: + - id type: object type: object served: true diff --git a/deploy/helm/kobs/crds/kobs.io_users.yaml b/deploy/helm/kobs/crds/kobs.io_users.yaml index 5f4773d53..f769755f1 100644 --- a/deploy/helm/kobs/crds/kobs.io_users.yaml +++ b/deploy/helm/kobs/crds/kobs.io_users.yaml @@ -31,22 +31,66 @@ spec: type: object spec: properties: - bio: - type: string cluster: type: string - email: - type: string - fullName: - type: string id: type: string name: type: string namespace: type: string - position: - type: string + permissions: + properties: + plugins: + items: + properties: + name: + type: string + permissions: + x-kubernetes-preserve-unknown-fields: true + required: + - name + type: object + 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 + profile: + properties: + bio: + type: string + email: + type: string + fullName: + type: string + position: + type: string + required: + - email + - fullName + type: object teams: items: properties: @@ -61,9 +105,8 @@ spec: type: object type: array required: - - email - - fullName - id + - profile type: object type: object served: true diff --git a/deploy/helm/kobs/templates/deployment.yaml b/deploy/helm/kobs/templates/deployment.yaml index fd4cee8f1..0c5176a1d 100644 --- a/deploy/helm/kobs/templates/deployment.yaml +++ b/deploy/helm/kobs/templates/deployment.yaml @@ -33,10 +33,10 @@ spec: imagePullPolicy: {{ .Values.kobs.image.pullPolicy }} args: - --development={{ .Values.kobs.settings.development }} - - --api.auth.default-team={{ .Values.kobs.settings.auth.defaultTeam }} - --api.auth.enabled={{ .Values.kobs.settings.auth.enabled }} - - --api.auth.header={{ .Values.kobs.settings.auth.header }} - - --api.auth.interval={{ .Values.kobs.settings.auth.interval }} + - --api.auth.header.teams={{ .Values.kobs.settings.auth.headerTeams }} + - --api.auth.header.user={{ .Values.kobs.settings.auth.headerUser }} + - --api.auth.session.interval={{ .Values.kobs.settings.auth.sessiontInterval }} - --clusters.cache-duration.namespaces={{ .Values.kobs.settings.clustersCacheDurationNamespaces }} - --log.format={{ .Values.kobs.settings.logFormat }} - --log.level={{ .Values.kobs.settings.logLevel }} diff --git a/deploy/helm/kobs/values.yaml b/deploy/helm/kobs/values.yaml index 2380d285a..2defdafc5 100644 --- a/deploy/helm/kobs/values.yaml +++ b/deploy/helm/kobs/values.yaml @@ -99,6 +99,15 @@ kobs: ## Specify additional environment variables for the kobs container. ## env: [] + ## For example the following can be used to set the token to sign the JWT token when authentication for kobs is + ## enabled. In this example we are using the "KOBS_API_AUTH_SESSION_TOKEN" key from a secret named "kobs" (must be + ## created manually) to set the "KOBS_API_AUTH_SESSION_TOKEN" environment variable. + ## + # - name: KOBS_API_AUTH_SESSION_TOKEN + # valueFrom: + # secretKeyRef: + # name: kobs + # key: KOBS_API_AUTH_SESSION_TOKEN ## Specify some settings like log level, log format, etc. for kobs. ## @@ -106,9 +115,9 @@ kobs: development: false auth: enabled: false - defaultTeam: "" - header: X-Auth-Request-Email - interval: 1h0m0s + headerTeams: X-Auth-Request-Groups + headerUser: X-Auth-Request-Email + sessiontInterval: 48h0m0s clustersCacheDurationNamespaces: 5m logFormat: console logLevel: info diff --git a/deploy/kustomize/crds/kobs.io_teams.yaml b/deploy/kustomize/crds/kobs.io_teams.yaml index 2437a7ba0..e8e621100 100644 --- a/deploy/kustomize/crds/kobs.io_teams.yaml +++ b/deploy/kustomize/crds/kobs.io_teams.yaml @@ -125,6 +125,8 @@ spec: type: array description: type: string + id: + type: string links: items: properties: @@ -145,7 +147,7 @@ spec: type: string permissions: properties: - custom: + plugins: items: properties: name: @@ -154,13 +156,8 @@ spec: x-kubernetes-preserve-unknown-fields: true required: - name - - permissions type: object type: array - plugins: - items: - type: string - type: array resources: items: properties: @@ -186,6 +183,8 @@ spec: - plugins - resources type: object + required: + - id type: object type: object served: true diff --git a/deploy/kustomize/crds/kobs.io_users.yaml b/deploy/kustomize/crds/kobs.io_users.yaml index 5f4773d53..f769755f1 100644 --- a/deploy/kustomize/crds/kobs.io_users.yaml +++ b/deploy/kustomize/crds/kobs.io_users.yaml @@ -31,22 +31,66 @@ spec: type: object spec: properties: - bio: - type: string cluster: type: string - email: - type: string - fullName: - type: string id: type: string name: type: string namespace: type: string - position: - type: string + permissions: + properties: + plugins: + items: + properties: + name: + type: string + permissions: + x-kubernetes-preserve-unknown-fields: true + required: + - name + type: object + 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 + profile: + properties: + bio: + type: string + email: + type: string + fullName: + type: string + position: + type: string + required: + - email + - fullName + type: object teams: items: properties: @@ -61,9 +105,8 @@ spec: type: object type: array required: - - email - - fullName - id + - profile type: object type: object served: true diff --git a/docs/configuration/authentication.md b/docs/configuration/authentication.md index 665bfce05..b1f7830b5 100644 --- a/docs/configuration/authentication.md +++ b/docs/configuration/authentication.md @@ -2,6 +2,12 @@ kobs hasn't any built in authentication mechanism. We recommend to run kobs behind a service like [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy/), which should handle the authentication of users. +## Permissions + +If the authentication / authorization middleware for kobs is enabled via the `--api.auth.enabled` flag, we use the value from the `--api.auth.header.user` and `--api.auth.header.teams` header to authorize the user to access a plugin or Kubernetes resource. These headers should be set by a service like the OAuth2 Proxy like it is shown in the following examples. + +The values from the headers are then used to get a [User CR](../resources/users.md) or a [Team CR](../resources/teams.md). If the user is part of multiple teams or when the permissions are set via the User CR and the Team CR, we merge all the permissions, so that the user can access all plugins and resources which are allowed for the user / teams. + ## Examples The following two examples show how you can setup kobs with an OAuth2 Proxy infront using the [NGINX Ingress Controller](https://kubernetes.github.io/ingress-nginx/) or [Istio](https://istio.io). Before you are looking into the examples, make sure you have setup your prefered [OAuth Provider](https://oauth2-proxy.github.io/oauth2-proxy/docs/configuration/oauth_provider). We will use Google as our OAuth Provider in the following, which requires a Client ID and a Client Secret. @@ -208,7 +214,7 @@ meshConfig: service: oauth2-proxy.kobs.svc.cluster.local port: "4180" includeHeadersInCheck: ["authorization", "cookie"] - headersToUpstreamOnAllow: ["authorization", "x-auth-request-email"] + headersToUpstreamOnAllow: ["authorization", "x-auth-request-email", "x-auth-request-groups"] ``` The external authorizer is now ready to be used by the authorization policy. diff --git a/docs/configuration/getting-started.md b/docs/configuration/getting-started.md index aa087f017..24622a9bd 100644 --- a/docs/configuration/getting-started.md +++ b/docs/configuration/getting-started.md @@ -11,14 +11,17 @@ The following command-line arguments and environment variables are available. | `--api.address` | `KOBS_API_ADDRESS` | The address, where the API server is listen on. | `:15220` | | `--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` | +| `--api.auth.header.teams string` | `KOBS_API_AUTH_HEADER_TEAMS` | The header, which contains the team ids. | `X-Auth-Request-Groups` | +| `--api.auth.header.user string` | `KOBS_API_AUTH_HEADER_USER` | The header, which contains the user id. | `X-Auth-Request-Email` | +| `--api.auth.session.interval duration` | `KOBS_API_AUTH_SESSION_INTERVAL` | The interval for how long a session is valid. | `48h0m0s` | +| `--api.auth.session.token string` | `KOBS_API_AUTH_SESSION_TOKEN` | The token to encrypt the session cookie. | | | `--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` | | `--config` | `KOBS_CONFIG` | Name of the configuration file. | `config.yaml` | +| `--development` | | Use development version | `false` | | `--log.format` | `KOBS_LOG_FORMAT` | Set the output format of the logs. Must be `plain` or `json`. | `plain` | -| `--log.level` | `KOBS_LOG_LEVEL` | Set the log level. Must be `trace`, `debug`, `info`, `warn`, `error`, `fatal` or `panic`. | `info` | +| `--log.level` | `KOBS_LOG_LEVEL` | Set the log level. Must be `debug`, `info`, `warn`, `error`, `fatal` or `panic`. | `info` | | `--metrics.address` | `KOBS_METRICS_ADDRESS` | The address, where the Prometheus metrics are served. | `:15221` | | `--version` | | Print version information. | `false` | diff --git a/docs/installation/helm.md b/docs/installation/helm.md index 25c4266da..a49139987 100644 --- a/docs/installation/helm.md +++ b/docs/installation/helm.md @@ -68,9 +68,9 @@ helm upgrade --install kobs kobs/kobs | `kobs.env` | Set additional environment variables for the kobs container. | `[]` | | `kobs.settings.development` | Run kobs in development mode. | `false` | | `kobs.settings.auth.enabled` | Enable the authentication and authorization middleware. | `false` | -| `kobs.settings.auth.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`. | `""` | -| `kobs.settings.auth.header` | The header, which contains the details about the authenticated user. | `X-Auth-Request-Email` | -| `kobs.settings.auth.interval` | The interval to refresh the internal users list and there permissions. | `1h0m0s` | +| `kobs.settings.auth.headerTeams` | The header, which contains the team ids. | `X-Auth-Request-Email` | +| `kobs.settings.auth.headerUser` | The header, which contains the user id. | `X-Auth-Request-Groups` | +| `kobs.settings.auth.sessiontInterval` | The interval for how long a session is valid. | `48h0m0s` | | `kobs.settings.clustersCacheDurationNamespaces` | The duration for how long the list of namespaces for each cluster should be cached. | `5m` | | `kobs.settings.logFormat` | Set the output format of the logs. Must be `console` or `json`. | `console` | | `kobs.settings.logLevel` | Set the log level. Must be `debug`, `info`, `warn`, `error`, `fatal` or `panic`. | `info` | diff --git a/docs/resources/teams.md b/docs/resources/teams.md index 4ff948e9c..33b77630c 100644 --- a/docs/resources/teams.md +++ b/docs/resources/teams.md @@ -12,10 +12,11 @@ In the following you can found the specification for the Team CRD. | Field | Type | Description | Required | | ----- | ---- | ----------- | -------- | +| id | string | A unique id for the team. 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 teams header (`--api.auth.header.teams`). | 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 | +| permissions | [Permissions](users.md#permissions) | Permissions for the team when the authentication / authorization middleware is enabled. | Yes | | dashboards | [[]Dashboard](#dashboard) | No | ### Link @@ -25,31 +26,6 @@ In the following you can found the specification for the Team CRD. | 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 | -| custom | [[]PermissionsCustom](#permissionscustom) | A list of custom permissions. | 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 | - -### PermissionsCustom - -Custom permissions can be used by plugin to have a fine grained permission model. - -| Field | Type | Description | Required | -| ----- | ---- | ----------- | -------- | -| name | string | The name of the plugin instance as it is defined in the configuration. | Yes | -| permissions | any | The permissions, which should be grant to a user. The format of this property is different for each plugin. You can find an example for each plugin on the corresponding plugin page in the documentation. | Yes | - ### Dashboard Define the dashboards, which should be used for the team. @@ -116,7 +92,7 @@ metadata: spec: permissions: plugins: - - "*" + - name: "*" resources: - clusters: - "*" diff --git a/docs/resources/users.md b/docs/resources/users.md index 8069c0ca1..062099e24 100644 --- a/docs/resources/users.md +++ b/docs/resources/users.md @@ -10,12 +10,19 @@ 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. If authentication and authorization is enabled this should be the value passed in the configured user details header (`--api.auth.header`). | 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 header (`--api.auth.header.user`). | Yes | +| profile | [Profile](#profile) | The users profile information. | Yes | +| teams | [[]Team](#team) | A list of links (e.g. a link to the teams Slack channel, Confluence page, etc.) | No | +| permissions | [Permissions](#permissions) | Permissions for the user when the authentication / authorization middleware is enabled. | Yes | + +### Profile + +| Field | Type | Description | Required | +| ----- | ---- | ----------- | -------- | | 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 | | bio | string | The bio of the user. The bio field supports markdown syntax. | No | -| links | [[]Team](#team) | A list of links (e.g. a link to the teams Slack channel, Confluence page, etc.) | No | ### Team @@ -25,6 +32,28 @@ In the following you can found the specification for the User CRD. | namespace | string | The namespace of the team, where the user is a member of. If this field isn't provided the namespace property of the user will be used. | No | | name | string | The name of the team. | Yes | +### Permissions + +| Field | Type | Description | Required | +| ----- | ---- | ----------- | -------- | +| plugins | [[]PermissionsPlugin](#permissionsplugin) | A list of plugins, which can be accessed by a user. If the list only contains one entry with the `name` set to `*`, the user can access all plugins. | Yes | +| resources | [[]PermissionResources](#permissionresources) | A list of resources, which can be accessed by the members of the team. | Yes | + +### PermissionsPlugin + +| Field | Type | Description | Required | +| ----- | ---- | ----------- | -------- | +| name | string | The name of the plugin instance as it is defined in the configuration. | Yes | +| permissions | any | The permissions, which should be grant to a user. The format of this property is different for each plugin. You can find an example for each plugin on the corresponding plugin page in the documentation. | No | + +### 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 | + ## Example ```yaml @@ -36,16 +65,17 @@ metadata: namespace: kobs spec: id: ricoberger - fullName: Rico Berger - email: admin@kobs.io - position: Site Reliability Engineer - bio: | - Site Reliability Engineer at Staffbase. Hacker, Gopher, Cloud Native Enthusiast. - - - [GitHub](https://github.com/ricoberger) - - [Twitter](https://twitter.com/rico_berger) - - [LinkedIn](https://www.linkedin.com/in/ricoberger/) - - [Xing](https://www.xing.com/profile/Rico_Berger5) + profile: + fullName: Rico Berger + email: admin@kobs.io + position: Site Reliability Engineer + bio: | + Site Reliability Engineer at Staffbase. Hacker, Gopher, Cloud Native Enthusiast. + + - [GitHub](https://github.com/ricoberger) + - [Twitter](https://twitter.com/rico_berger) + - [LinkedIn](https://www.linkedin.com/in/ricoberger/) + - [Xing](https://www.xing.com/profile/Rico_Berger5) teams: - name: team-diablo ``` diff --git a/go.mod b/go.mod index 33eb33f77..9954d7a53 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/go-chi/cors v1.2.0 github.com/go-chi/render v1.0.1 github.com/go-sql-driver/mysql v1.6.0 + github.com/golang-jwt/jwt/v4 v4.2.0 github.com/gorilla/websocket v1.4.2 github.com/kiali/kiali v1.38.0 github.com/lib/pq v1.10.4 diff --git a/go.sum b/go.sum index 2c46b2915..082d1ad8a 100644 --- a/go.sum +++ b/go.sum @@ -281,6 +281,8 @@ github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zV github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.2.0 h1:besgBTC8w8HjP6NzQdxwKH9Z5oQMZ24ThTrHp3cZ8eU= +github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= diff --git a/pkg/api/apis/team/v1beta1/types.go b/pkg/api/apis/team/v1beta1/types.go index c2379ff3c..f421f01e0 100644 --- a/pkg/api/apis/team/v1beta1/types.go +++ b/pkg/api/apis/team/v1beta1/types.go @@ -1,10 +1,10 @@ package v1beta1 import ( - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" dashboard "github.com/kobsio/kobs/pkg/api/apis/dashboard/v1beta1" + user "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" ) // +genclient @@ -32,10 +32,11 @@ type TeamSpec struct { Cluster string `json:"cluster,omitempty"` Namespace string `json:"namespace,omitempty"` Name string `json:"name,omitempty"` + ID string `json:"id"` Description string `json:"description,omitempty"` Links []Link `json:"links,omitempty"` Logo string `json:"logo,omitempty"` - Permissions Permissions `json:"permissions,omitempty"` + Permissions user.Permissions `json:"permissions,omitempty"` Dashboards []dashboard.Reference `json:"dashboards,omitempty"` } @@ -50,20 +51,3 @@ type Reference struct { Name string `json:"name"` Description string `json:"description,omitempty"` } - -type Permissions struct { - Plugins []string `json:"plugins"` - Resources []PermissionsResources `json:"resources"` - Custom []PermissionsCustom `json:"custom,omitempty"` -} - -type PermissionsCustom struct { - Name string `json:"name"` - Permissions apiextensionsv1.JSON `json:"permissions"` -} - -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 fd44d0a89..8f8d73782 100644 --- a/pkg/api/apis/team/v1beta1/zz_generated.deepcopy.go +++ b/pkg/api/apis/team/v1beta1/zz_generated.deepcopy.go @@ -42,89 +42,6 @@ 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]) - } - } - if in.Custom != nil { - in, out := &in.Custom, &out.Custom - *out = make([]PermissionsCustom, 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 *PermissionsCustom) DeepCopyInto(out *PermissionsCustom) { - *out = *in - in.Permissions.DeepCopyInto(&out.Permissions) - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PermissionsCustom. -func (in *PermissionsCustom) DeepCopy() *PermissionsCustom { - if in == nil { - return nil - } - out := new(PermissionsCustom) - 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 diff --git a/pkg/api/apis/user/v1beta1/types.go b/pkg/api/apis/user/v1beta1/types.go index 1c91d15e3..47781de09 100644 --- a/pkg/api/apis/user/v1beta1/types.go +++ b/pkg/api/apis/user/v1beta1/types.go @@ -1,6 +1,7 @@ package v1beta1 import ( + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -26,15 +27,20 @@ type UserList struct { } type UserSpec struct { - Cluster string `json:"cluster,omitempty"` - Namespace string `json:"namespace,omitempty"` - Name string `json:"name,omitempty"` - ID string `json:"id"` - FullName string `json:"fullName"` - Email string `json:"email"` - Position string `json:"position,omitempty"` - Bio string `json:"bio,omitempty"` - Teams []TeamReference `json:"teams,omitempty"` + Cluster string `json:"cluster,omitempty"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name,omitempty"` + ID string `json:"id"` + Profile Profile `json:"profile"` + Teams []TeamReference `json:"teams,omitempty"` + Permissions Permissions `json:"permissions,omitempty"` +} + +type Profile struct { + FullName string `json:"fullName"` + Email string `json:"email"` + Position string `json:"position,omitempty"` + Bio string `json:"bio,omitempty"` } type TeamReference struct { @@ -42,3 +48,19 @@ type TeamReference struct { Namespace string `json:"namespace,omitempty"` Name string `json:"name"` } + +type Permissions struct { + Plugins []Plugin `json:"plugins"` + Resources []Resources `json:"resources"` +} + +type Plugin struct { + Name string `json:"name"` + Permissions apiextensionsv1.JSON `json:"permissions,omitempty"` +} + +type Resources struct { + Clusters []string `json:"clusters"` + Namespaces []string `json:"namespaces"` + Resources []string `json:"resources"` +} diff --git a/pkg/api/apis/user/v1beta1/zz_generated.deepcopy.go b/pkg/api/apis/user/v1beta1/zz_generated.deepcopy.go index bd9b87367..c350f7fc7 100644 --- a/pkg/api/apis/user/v1beta1/zz_generated.deepcopy.go +++ b/pkg/api/apis/user/v1beta1/zz_generated.deepcopy.go @@ -25,6 +25,100 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// 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([]Plugin, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]Resources, 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 *Plugin) DeepCopyInto(out *Plugin) { + *out = *in + in.Permissions.DeepCopyInto(&out.Permissions) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Plugin. +func (in *Plugin) DeepCopy() *Plugin { + if in == nil { + return nil + } + out := new(Plugin) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Profile) DeepCopyInto(out *Profile) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Profile. +func (in *Profile) DeepCopy() *Profile { + if in == nil { + return nil + } + out := new(Profile) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Resources) DeepCopyInto(out *Resources) { + *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 Resources. +func (in *Resources) DeepCopy() *Resources { + if in == nil { + return nil + } + out := new(Resources) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TeamReference) DeepCopyInto(out *TeamReference) { *out = *in @@ -104,11 +198,13 @@ func (in *UserList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UserSpec) DeepCopyInto(out *UserSpec) { *out = *in + out.Profile = in.Profile if in.Teams != nil { in, out := &in.Teams, &out.Teams *out = make([]TeamReference, len(*in)) copy(*out, *in) } + in.Permissions.DeepCopyInto(&out.Permissions) return } diff --git a/pkg/api/middleware/auth/auth.go b/pkg/api/middleware/auth/auth.go index bcb97094c..8ce50f33a 100644 --- a/pkg/api/middleware/auth/auth.go +++ b/pkg/api/middleware/auth/auth.go @@ -4,13 +4,13 @@ import ( "context" "net/http" "strings" - "sync" "time" 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/auth/jwt" "github.com/kobsio/kobs/pkg/api/middleware/errresponse" "github.com/kobsio/kobs/pkg/log" @@ -19,191 +19,201 @@ import ( // 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 + enabled bool + headerUser string + headerTeams string + sessionToken string + sessionInterval time.Duration + clusters *clusters.Clusters } -// 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 containsTeam(teamID string, teamIDs []string) bool { + for _, id := range teamIDs { + if id == teamID { + return true + } + } + + return false +} + +func (a *Auth) getUser(ctx context.Context, userID string, teamIDs []string) (authContext.User, error) { + authContextUser := authContext.User{ID: userID} + + var users []user.UserSpec + var teams []team.TeamSpec + + for _, c := range a.clusters.Clusters { + tmpUsers, err := c.GetUsers(ctx, "") + if err != nil { + return authContextUser, err + } + + users = append(users, tmpUsers...) + + if teamIDs != nil { + tmpTeams, err := c.GetTeams(ctx, "") + if err != nil { + return authContextUser, err + } + + teams = append(teams, tmpTeams...) + } + } + + for _, u := range users { + if u.ID == authContextUser.ID { + authContextUser.Cluster = u.Cluster + authContextUser.Namespace = u.Namespace + authContextUser.Name = u.Name + authContextUser.ID = u.ID + authContextUser.Profile = u.Profile + authContextUser.Teams = u.Teams + authContextUser.Permissions.Plugins = append(authContextUser.Permissions.Plugins, u.Permissions.Plugins...) + authContextUser.Permissions.Resources = append(authContextUser.Permissions.Resources, u.Permissions.Resources...) + break + } + } + + for _, t := range teams { + if containsTeam(t.ID, teamIDs) { + authContextUser.Permissions.Plugins = append(authContextUser.Permissions.Plugins, t.Permissions.Plugins...) + authContextUser.Permissions.Resources = append(authContextUser.Permissions.Resources, t.Permissions.Resources...) + } + } + + return authContextUser, nil +} + +// Handler is the handler for the auth middleware. In the middleware we check if authentication is enabled. If this is +// the case we check if the request contains a valid jwt cookie in the "kobs-auth" token. If we do not found a valid +// token we try to create a new one and set it as cookie, before we return an unauthorized error. If the authorization +// succeeds we also inject the user in the request context. 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) + userID := r.Header.Get(a.headerUser) + teamIDs := strings.Split(r.Header.Get(a.headerTeams), ",") + // If the authentication / authorization middleware is enabled, we have to check the permissions of the user by + // using the provided information from the user and teams header. if a.enabled { + // If the request doesn't contain a user id we return an unauthorized error, because at least the user id is + // required to perform any kind of authorization. if userID == "" { + log.Warn(r.Context(), "User ID is missing.") errresponse.Render(w, r, nil, http.StatusUnauthorized, "Unauthorized") return } - var user authContext.User + // Get the value of the auth cookie. When we can not read the value of the cookie, we try to create a cookie + // for the user. If we found the user or the teams he is part of we set a cookie for the user. If not we + // return an unauthorized error. + cookie, err := r.Cookie("kobs-auth") + if err != nil { + log.Warn(r.Context(), "Error while getting \"kobs-auth\" cookie.", zap.Error(err)) - u, ok := a.users.Load(userID) - if !ok { - user = authContext.User{ - ID: userID, - HasProfile: false, - Permissions: a.defaultPermissions, + user, err := a.getUser(r.Context(), userID, teamIDs) + if err != nil { + log.Warn(r.Context(), "Could not get user.", zap.Error(err), zap.String("user", userID), zap.Strings("teams", teamIDs)) + errresponse.Render(w, r, nil, http.StatusUnauthorized, "Unauthorized") + return } - } 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") + token, err := jwt.CreateToken(user, a.sessionToken, a.sessionInterval) + if err != nil { + log.Warn(r.Context(), "Could not create jwt token.", zap.Error(err)) + errresponse.Render(w, r, nil, http.StatusUnauthorized, "Unauthorized") return } - } - ctx = context.WithValue(ctx, authContext.UserKey, user) + http.SetCookie(w, &http.Cookie{ + Name: "kobs-auth", + Value: token, + }) + ctx = context.WithValue(ctx, authContext.UserKey, user) + } else { + // We have to check if the token from the "kobs-auth" cookie is still valid. If this is the case we are + // done. If the token isn't valid anymore, we try to create a new token and update the cookie value with + // the new token. + log.Debug(r.Context(), "Found existing cookie.") + + user, err := jwt.ValidateToken(cookie.Value, a.sessionToken) + if err != nil || user == nil { + log.Warn(r.Context(), "Token validation failed.", zap.Error(err)) + + newUser, err := a.getUser(r.Context(), userID, teamIDs) + if err != nil { + log.Warn(r.Context(), "Could not get user.", zap.Error(err), zap.String("user", userID), zap.Strings("teams", teamIDs)) + errresponse.Render(w, r, nil, http.StatusUnauthorized, "Unauthorized") + return + } + + token, err := jwt.CreateToken(newUser, a.sessionToken, a.sessionInterval) + if err != nil { + log.Warn(r.Context(), "Could not create jwt token.", zap.Error(err)) + errresponse.Render(w, r, nil, http.StatusUnauthorized, "Unauthorized") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "kobs-auth", + Value: token, + }) + ctx = context.WithValue(ctx, authContext.UserKey, newUser) + } else { + ctx = context.WithValue(ctx, authContext.UserKey, *user) + } + } } else { + // If authentication is disabled, we still check if the request contains a user id, which can be used for a + // leightweighted audit loggin. If there is no user id we set a user with a static id, so that we still have + // an valid user object which can be used by the plugins to simplify the authorization logic there. if userID == "" { userID = "kobs.io" } 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{"*"}, - }}, + ID: userID, + Permissions: user.Permissions{ + Plugins: []user.Plugin{{Name: "*"}}, + Resources: []user.Resources{{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.Info(nil, "Authentication and authorization middleware is disabled.") - return - } - - err := a.getPermissions() - if err != nil { - log.Error(nil, "Failed to refresh users and permissions.", zap.Error(err)) - } else { - log.Info(nil, "Refreshed users and permissions.") - } - - ticker := time.NewTicker(a.refreshInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - err := a.getPermissions() - if err != nil { - log.Error(nil, "Failed to refresh users and permissions.", zap.Error(err)) - } else { - log.Info(nil, "Refreshed users and permissions.") - } - } - } -} - -// 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.Warn(nil, "Could not get teams.", zap.Error(err), zap.String("cluster", cluster.GetName())) - } - - teams = append(teams, t...) - - u, err := cluster.GetUsers(ctx, "") - if err != nil { - log.Warn(nil, "Could not get users.", zap.Error(err), zap.String("cluster", cluster.GetName())) - } - - 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 nil -} - -func getUserPermissions(user user.UserSpec, teams []team.TeamSpec) authContext.User { - u := authContext.User{ - ID: user.ID, - HasProfile: true, - Profile: user, - } - - for _, userTeam := range user.Teams { - c := userTeam.Cluster - if c == "" { - c = user.Cluster - } - - n := userTeam.Namespace - if n == "" { - n = user.Namespace - } + // At this point the request context contains a valid user, so if authentication is enabled we have can check at + // this point if the user can access a plugin. + if a.enabled { + urlPaths := strings.Split(r.URL.Path, "/") + if len(urlPaths) >= 4 && urlPaths[1] == "api" && urlPaths[2] == "plugins" { + user, err := authContext.GetUser(ctx) + if err != nil { + log.Warn(r.Context(), "User is now allowed to access the plugin.", zap.String("plugin", urlPaths[3])) + errresponse.Render(w, r, nil, http.StatusForbidden, "Your are not allowed to access the plugin") + return + } - 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...) - u.Permissions.Custom = append(u.Permissions.Custom, team.Permissions.Custom...) + if !user.HasPluginAccess(urlPaths[3]) { + log.Warn(r.Context(), "User is now allowed to access the plugin.", zap.String("plugin", urlPaths[3])) + errresponse.Render(w, r, nil, http.StatusForbidden, "Your are not allowed to access the plugin") + return + } } } - } - return u + next.ServeHTTP(w, r.WithContext(ctx)) + }) } // New returns a new authentication and authorization object. -func New(enabled bool, userHeader, defaultTeam string, interval time.Duration, clusters *clusters.Clusters) *Auth { +func New(enabled bool, headerUser, headerTeams, sessionToken string, sessionInterval time.Duration, clusters *clusters.Clusters) *Auth { return &Auth{ enabled: enabled, - userHeader: userHeader, - defaultTeam: defaultTeam, - refreshInterval: interval, + headerUser: headerUser, + headerTeams: headerTeams, + sessionToken: sessionToken, + sessionInterval: sessionInterval, clusters: clusters, } } diff --git a/pkg/api/middleware/auth/context/context.go b/pkg/api/middleware/auth/context/context.go index 323390f21..c2fe93c63 100644 --- a/pkg/api/middleware/auth/context/context.go +++ b/pkg/api/middleware/auth/context/context.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - team "github.com/kobsio/kobs/pkg/api/apis/team/v1beta1" user "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" ) @@ -17,16 +16,19 @@ 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"` + Cluster string `json:"cluster"` + Namespace string `json:"namespace"` + Name string `json:"name"` + ID string `json:"id"` + Profile user.Profile `json:"profile"` + Teams []user.TeamReference `json:"teams"` + Permissions user.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 == "*" { + if p.Name == plugin || p.Name == "*" { return true } } @@ -88,13 +90,9 @@ func (u *User) HasResourceAccess(cluster, namespace, name string) bool { // GetPluginPermissions returns the custom plugin permissions for a user. For that the name of the plugin must be // provided. func (u *User) GetPluginPermissions(name string) ([][]byte, error) { - if u.Permissions.Custom == nil { - return nil, fmt.Errorf("custom permissions are empty for the user") - } - var allCustomPermissions [][]byte - for _, plugin := range u.Permissions.Custom { + for _, plugin := range u.Permissions.Plugins { if plugin.Name == name { allCustomPermissions = append(allCustomPermissions, plugin.Permissions.Raw) } diff --git a/pkg/api/middleware/auth/context/context_test.go b/pkg/api/middleware/auth/context/context_test.go index ec1539fde..8457fa728 100644 --- a/pkg/api/middleware/auth/context/context_test.go +++ b/pkg/api/middleware/auth/context/context_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - team "github.com/kobsio/kobs/pkg/api/apis/team/v1beta1" + user "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" "github.com/stretchr/testify/require" ) @@ -14,11 +14,11 @@ func TestHasPluginAccess(t *testing.T) { 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}, + {user: User{ID: "user1", Permissions: user.Permissions{Plugins: []user.Plugin{{Name: "*"}}}}, expectedHasAccess: true}, + {user: User{ID: "user2", Permissions: user.Permissions{Plugins: []user.Plugin{{Name: "plugin1"}}}}, expectedHasAccess: true}, + {user: User{ID: "user3", Permissions: user.Permissions{Plugins: []user.Plugin{{Name: "plugin1"}, {Name: "plugin2"}}}}, expectedHasAccess: true}, + {user: User{ID: "user4", Permissions: user.Permissions{Plugins: []user.Plugin{{Name: "plugin2"}}}}, expectedHasAccess: false}, + {user: User{ID: "user5", Permissions: user.Permissions{Plugins: []user.Plugin{{Name: "plugin2"}, {Name: "*"}}}}, expectedHasAccess: true}, } { t.Run(tc.user.ID, func(t *testing.T) { actualHasAccess := tc.user.HasPluginAccess("plugin1") @@ -32,14 +32,14 @@ func TestHasClusterAccess(t *testing.T) { 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}, + {user: User{ID: "user1", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"*"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user2", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user3", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster1", "cluster2"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user4", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster2"}}}}}, expectedHasAccess: false}, + {user: User{ID: "user5", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster2", "*"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user6", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster2"}}, {Clusters: []string{"*"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user7", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster2"}}, {Clusters: []string{"cluster1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user8", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster2"}}, {Clusters: []string{"cluster3"}}}}}, expectedHasAccess: false}, } { t.Run(tc.user.ID, func(t *testing.T) { actualHasAccess := tc.user.HasClusterAccess("cluster1") @@ -53,21 +53,21 @@ func TestHasNamespaceAccess(t *testing.T) { 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: "user1", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"*"}, Namespaces: []string{"*"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user2", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"*"}, Namespaces: []string{"namespace1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user3", Permissions: user.Permissions{Resources: []user.Resources{{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: "user4", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster1"}, Namespaces: []string{"*"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user5", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster1"}, Namespaces: []string{"namespace1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user6", Permissions: user.Permissions{Resources: []user.Resources{{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: "user7", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster2"}, Namespaces: []string{"*"}}}}}, expectedHasAccess: false}, + {user: User{ID: "user8", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster2"}, Namespaces: []string{"namespace1"}}}}}, expectedHasAccess: false}, + {user: User{ID: "user9", Permissions: user.Permissions{Resources: []user.Resources{{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}, + {user: User{ID: "user10", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster2"}, Namespaces: []string{"*"}}, {Clusters: []string{"cluster1"}, Namespaces: []string{"*"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user11", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster2"}, Namespaces: []string{"*"}}, {Clusters: []string{"cluster1"}, Namespaces: []string{"namespace1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user12", Permissions: user.Permissions{Resources: []user.Resources{{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") @@ -81,23 +81,23 @@ func TestHasResourceAccess(t *testing.T) { 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: "user1", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"*"}, Namespaces: []string{"*"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user2", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"*"}, Namespaces: []string{"namespace1"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user3", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"*"}, Namespaces: []string{"namespace2"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: false}, + {user: User{ID: "user2", Permissions: user.Permissions{Resources: []user.Resources{{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: "user4", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster1"}, Namespaces: []string{"*"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user5", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster1"}, Namespaces: []string{"namespace1"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user6", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster1"}, Namespaces: []string{"namespace2"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: false}, + {user: User{ID: "user6", Permissions: user.Permissions{Resources: []user.Resources{{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: "user7", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster2"}, Namespaces: []string{"*"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: false}, + {user: User{ID: "user8", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster2"}, Namespaces: []string{"namespace1"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: false}, + {user: User{ID: "user9", Permissions: user.Permissions{Resources: []user.Resources{{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}, + {user: User{ID: "user10", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster2"}, Namespaces: []string{"*"}, Resources: []string{"resource1"}}, {Clusters: []string{"cluster1"}, Namespaces: []string{"*"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user11", Permissions: user.Permissions{Resources: []user.Resources{{Clusters: []string{"cluster2"}, Namespaces: []string{"*"}, Resources: []string{"resource1"}}, {Clusters: []string{"cluster1"}, Namespaces: []string{"namespace1"}, Resources: []string{"resource1"}}}}}, expectedHasAccess: true}, + {user: User{ID: "user12", Permissions: user.Permissions{Resources: []user.Resources{{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") diff --git a/pkg/api/middleware/auth/handler.go b/pkg/api/middleware/auth/handler.go index bfd73661b..69360445a 100644 --- a/pkg/api/middleware/auth/handler.go +++ b/pkg/api/middleware/auth/handler.go @@ -14,41 +14,47 @@ import ( ) var ( - flagEnabled bool - flagUserHeader string - flagInterval time.Duration - flagDefaultTeam string + flagEnabled bool + flagHeaderUser string + flagHeaderTeams string + flagSessionToken string + flagSessionInterval time.Duration ) func init() { - defaultHeader := "X-Auth-Request-Email" - if os.Getenv("KOBS_API_AUTH_HEADER") != "" { - defaultHeader = os.Getenv("KOBS_API_AUTH_HEADER") + defaultHeaderUser := "X-Auth-Request-Email" + if os.Getenv("KOBS_API_AUTH_HEADER_USER") != "" { + defaultHeaderUser = os.Getenv("KOBS_API_AUTH_HEADER_USER") } - 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 - } + defaultHeaderTeams := "X-Auth-Request-Groups" + if os.Getenv("KOBS_API_AUTH_HEADER_TEAMS") != "" { + defaultHeaderTeams = os.Getenv("KOBS_API_AUTH_HEADER_TEAMS") + } + + defaultSessionToken := "" + if os.Getenv("KOBS_API_AUTH_SESSION_TOKEN") != "" { + defaultSessionToken = os.Getenv("KOBS_API_AUTH_SESSION_TOKEN") } - defaultTeam := "" - if os.Getenv("KOBS_API_AUTH_DEFAULT_TEAM") != "" { - defaultTeam = os.Getenv("KOBS_API_AUTH_DEFAULT_TEAM") + defaultSessionInterval := time.Duration(48 * time.Hour) + if os.Getenv("KOBS_API_AUTH_SESSION_INTERVAL") != "" { + parsedDefaultSessionInterval, err := time.ParseDuration(os.Getenv("KOBS_API_AUTH_SESSION_INTERVAL")) + if err == nil && parsedDefaultSessionInterval > 60*time.Second { + defaultSessionInterval = parsedDefaultSessionInterval + } } 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.") + flag.StringVar(&flagHeaderUser, "api.auth.header.user", defaultHeaderUser, "The header, which contains the user id.") + flag.StringVar(&flagHeaderTeams, "api.auth.header.teams", defaultHeaderTeams, "The header, which contains the team ids.") + flag.StringVar(&flagSessionToken, "api.auth.session.token", defaultSessionToken, "The token to encrypt the session cookie.") + flag.DurationVar(&flagSessionInterval, "api.auth.session.interval", defaultSessionInterval, "The interval for how long a session is valid.") } -// Handler creates a new Auth handler with passed options. +// Handler creates a new Auth handler with the options defined via the above flags. func Handler(clusters *clusters.Clusters) func(next http.Handler) http.Handler { - a := New(flagEnabled, flagUserHeader, flagDefaultTeam, flagInterval, clusters) - go a.GetPermissions() + a := New(flagEnabled, flagHeaderUser, flagHeaderTeams, flagSessionToken, flagSessionInterval, clusters) return a.Handler } diff --git a/pkg/api/middleware/auth/jwt/jwt.go b/pkg/api/middleware/auth/jwt/jwt.go new file mode 100644 index 000000000..c2739865f --- /dev/null +++ b/pkg/api/middleware/auth/jwt/jwt.go @@ -0,0 +1,51 @@ +package jwt + +import ( + "fmt" + "time" + + authContext "github.com/kobsio/kobs/pkg/api/middleware/auth/context" + + goJWT "github.com/golang-jwt/jwt/v4" +) + +// CustomClaims is the struct which defines the claims for our jwt tokens. +type CustomClaims struct { + User authContext.User + goJWT.RegisteredClaims +} + +// ValidateToken validates a given jwt token and returns the user from the claims or an error when the validation fails. +func ValidateToken(tokenString, sessionToken string) (*authContext.User, error) { + token, err := goJWT.ParseWithClaims(tokenString, &CustomClaims{}, func(token *goJWT.Token) (interface{}, error) { + if _, ok := token.Method.(*goJWT.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + + return []byte(sessionToken), nil + }) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*CustomClaims) + if ok && token.Valid { + return &claims.User, nil + } + + return nil, fmt.Errorf("invalid jwt claims") +} + +// CreateToken creates a new signed jwt token with the user information saved in the claims. +func CreateToken(user authContext.User, sessionToken string, sessionInterval time.Duration) (string, error) { + claims := CustomClaims{ + user, + goJWT.RegisteredClaims{ + ExpiresAt: goJWT.NewNumericDate(time.Now().Add(sessionInterval)), + Issuer: "kobs.io", + }, + } + + token := goJWT.NewWithClaims(goJWT.SigningMethodHS256, claims) + return token.SignedString([]byte(sessionToken)) +} diff --git a/plugins/core/src/components/app/Home.tsx b/plugins/core/src/components/app/Home.tsx index 46d4e19b9..7f257b3b6 100644 --- a/plugins/core/src/components/app/Home.tsx +++ b/plugins/core/src/components/app/Home.tsx @@ -60,7 +60,7 @@ const HomePage: React.FunctionComponent = () => { - {authContext.user.hasProfile && ( + {authContext.user.profile.email && ( { style={{ height: '64px', width: '64px' }} />
{authContext.user.profile.fullName}
-
{authContext.user.profile.position}
+ {authContext.user.profile.position && ( +
{authContext.user.profile.position}
+ )}

 

- {authContext.user.profile.teams && } + {authContext.user.teams && ( + + )}
); }; diff --git a/plugins/core/src/components/app/account/AccountTeams.tsx b/plugins/core/src/components/app/account/AccountTeams.tsx index 9b947af5f..3ec3f9d91 100644 --- a/plugins/core/src/components/app/account/AccountTeams.tsx +++ b/plugins/core/src/components/app/account/AccountTeams.tsx @@ -4,36 +4,45 @@ import { useQuery } from 'react-query'; import AccountTeamsItem from './AccountTeamsItem'; import { ITeam } from '../../../crds/team'; -import { IUser } from '../../../crds/user'; +import { IUserTeamReference } from '../../../crds/user'; export interface IAccountTeamsProps { - user: IUser; + cluster: string; + namespace: string; + teams: IUserTeamReference[]; } -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(); +const AccountTeams: React.FunctionComponent = ({ + cluster, + namespace, + teams, +}: IAccountTeamsProps) => { + const { isError, isLoading, data } = useQuery( + ['users/teams', cluster, namespace, teams], + async () => { + try { + const response = await fetch(`/api/plugins/users/teams?cluster=${cluster}&namespace=${namespace}`, { + body: JSON.stringify({ + teams: 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); + if (response.status >= 200 && response.status < 300) { + return json; } else { - throw new Error('An unknown error occured'); + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } } + } catch (err) { + throw err; } - } catch (err) { - throw err; - } - }); + }, + ); if (isLoading || isError || !data) { return null; diff --git a/plugins/core/src/context/AuthContext.tsx b/plugins/core/src/context/AuthContext.tsx index 328bc4805..fa61a54af 100644 --- a/plugins/core/src/context/AuthContext.tsx +++ b/plugins/core/src/context/AuthContext.tsx @@ -2,18 +2,27 @@ import { Alert, AlertActionLink, AlertVariant, Spinner } from '@patternfly/react import { QueryObserverResult, useQuery } from 'react-query'; import React from 'react'; -import { IUser } from '../crds/user'; +import { IUserProfile, IUserTeamReference } from '../crds/user'; export interface IAuth { + cluster: string; + namespace: string; + name: string; id: string; - hasProfile: boolean; - profile: IUser; + profile: IUserProfile; + teams: IUserTeamReference[]; permissions: IAuthPermissions; } export interface IAuthPermissions { - plugins: string[]; - resources: IAuthPermissionsResources[]; + plugins?: IAuthPermissionsPlugin[]; + resources?: IAuthPermissionsResources[]; +} + +export interface IAuthPermissionsPlugin { + name: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + permissions: any; } export interface IAuthPermissionsResources { @@ -34,20 +43,19 @@ export const AuthContext = React.createContext({ return false; }, user: { - hasProfile: false, + cluster: '', id: '', + name: '', + namespace: '', permissions: { plugins: [], resources: [], }, profile: { - cluster: '', email: '', fullName: '', - id: '', - name: '', - namespace: '', }, + teams: [], }, }); @@ -86,9 +94,9 @@ export const AuthContextProvider: React.FunctionComponent { - if (data) { - for (const plugin of data?.permissions.plugins) { - if (plugin === name || plugin === '*') { + if (data && data.permissions.plugins) { + for (const plugin of data.permissions.plugins) { + if (plugin.name === name || plugin.name === '*') { return true; } } diff --git a/plugins/core/src/crds/team.ts b/plugins/core/src/crds/team.ts index 7acd8f6fc..0edc0287a 100644 --- a/plugins/core/src/crds/team.ts +++ b/plugins/core/src/crds/team.ts @@ -5,6 +5,7 @@ export interface ITeam { cluster: string; namespace: string; name: string; + id: string; description?: string; links?: ITeamLink[]; logo?: string; diff --git a/plugins/core/src/crds/user.ts b/plugins/core/src/crds/user.ts index fa544c490..74b08febf 100644 --- a/plugins/core/src/crds/user.ts +++ b/plugins/core/src/crds/user.ts @@ -4,11 +4,15 @@ export interface IUser { namespace: string; name: string; id: string; + profile: IUserProfile; + teams?: IUserTeamReference[]; +} + +export interface IUserProfile { fullName: string; email: string; position?: string; bio?: string; - teams?: IUserTeamReference[]; } export interface IUserTeamReference { diff --git a/plugins/users/src/components/home/Home.tsx b/plugins/users/src/components/home/Home.tsx index c71a1c2a7..59825ec87 100644 --- a/plugins/users/src/components/home/Home.tsx +++ b/plugins/users/src/components/home/Home.tsx @@ -101,20 +101,12 @@ const Home: React.FunctionComponent = () => { : user.cluster.includes(debouncedSearchTerm) || user.namespace.includes(debouncedSearchTerm) || user.name.includes(debouncedSearchTerm) || - user.fullName.includes(debouncedSearchTerm) || - user.email.includes(debouncedSearchTerm) || - user.position?.includes(debouncedSearchTerm), + user.profile.fullName.includes(debouncedSearchTerm) || + user.profile.email.includes(debouncedSearchTerm), ) .map((user, index) => ( - + ))} diff --git a/plugins/users/src/components/page/User.tsx b/plugins/users/src/components/page/User.tsx index 1e87fdc18..1872c55c9 100644 --- a/plugins/users/src/components/page/User.tsx +++ b/plugins/users/src/components/page/User.tsx @@ -96,12 +96,12 @@ const User: React.FunctionComponent = () => {
-
{data.fullName}
-
{data.position}
+
{data.profile.fullName}
+ {data.profile.position &&
{data.profile.position}
}
@@ -109,12 +109,12 @@ const User: React.FunctionComponent = () => { {data.teams && } - {data.bio && ( + {data.profile?.bio && ( - {data.bio} + {data.profile.bio} diff --git a/plugins/users/src/components/page/Users.tsx b/plugins/users/src/components/page/Users.tsx index dc6a86501..319162208 100644 --- a/plugins/users/src/components/page/Users.tsx +++ b/plugins/users/src/components/page/Users.tsx @@ -88,9 +88,7 @@ const Users: React.FunctionComponent = ({ displayName, description cluster={user.cluster} namespace={user.namespace} name={user.name} - fullName={user.fullName} - email={user.email} - position={user.position} + profile={user.profile} /> ))} diff --git a/plugins/users/src/components/page/UsersItem.tsx b/plugins/users/src/components/page/UsersItem.tsx index a133f47a5..8804cff77 100644 --- a/plugins/users/src/components/page/UsersItem.tsx +++ b/plugins/users/src/components/page/UsersItem.tsx @@ -1,15 +1,13 @@ import { Avatar, Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core'; import React from 'react'; -import { LinkWrapper, getGravatarImageUrl } from '@kobsio/plugin-core'; +import { IUserProfile, LinkWrapper, getGravatarImageUrl } from '@kobsio/plugin-core'; interface IUsersItemProps { cluster: string; namespace: string; name: string; - fullName: string; - email: string; - position?: string; + profile: IUserProfile; } // UsersItem renders a single user in a Card component. The Card is wrapped by our LinkWrapper so that the user is @@ -18,22 +16,20 @@ const UsersItem: React.FunctionComponent = ({ cluster, namespace, name, - fullName, - email, - position, + profile, }: IUsersItemProps) => { return ( - {fullName} + {profile.fullName} - {position ?

{position}

:

 

}
+ {profile?.position ?

{profile.position}

:

 

}
); diff --git a/plugins/users/src/components/panel/Users.tsx b/plugins/users/src/components/panel/Users.tsx index 0dc507f07..42e5de6c8 100644 --- a/plugins/users/src/components/panel/Users.tsx +++ b/plugins/users/src/components/panel/Users.tsx @@ -71,14 +71,7 @@ const Users: React.FunctionComponent = ({ cluster, namespace, name {data.map((user, index) => ( - + ))}