diff --git a/dev/config-kubernetes.yaml b/dev/config-kubernetes.yaml index 4d61b1aad6..b59c4e1a9a 100644 --- a/dev/config-kubernetes.yaml +++ b/dev/config-kubernetes.yaml @@ -6,7 +6,7 @@ security: provider: kubernetes: enable: true - kubeconfig: "dev/kubernests/local/perses-backend" + kubeconfig: "dev/kubernetes/local/perses-backend" authentication: providers: kubernetes: diff --git a/dev/kubernetes/README.md b/dev/kubernetes/README.md index 7e93c67bab..d41c27276b 100644 --- a/dev/kubernetes/README.md +++ b/dev/kubernetes/README.md @@ -1,12 +1,28 @@ ## Required Technologies 1. [Kind](https://kind.sigs.k8s.io/docs/user/quick-start/#installation) - run a kubernetes cluster locally 2. [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/#install-kubectl-on-linux) - CLI to connect to a kubernetes cluster -3. [Caddy](https://caddyserver.com/docs/install) - reverse proxy between frontend and backend to inject kubernetes Authorization token header +3. [Caddy](https://caddyserver.com/docs/install) - reverse proxy to inject kubernetes Authorization token header 4. [tmux](https://github.com/tmux/tmux/wiki/Installing) - terminal multiplexer for single script startup -## Starting kubernetes development +## Running Locally 1. `./scripts/run-kubernetes.sh` - Starts a kind cluster and adds all relevant data (CRD's, users, permissions) -2. `./scripts/k8s-dev.sh` - Start a tmux session with the frontend, reverse-proxy for Autorization token, and + +After the kind cluster has been started the backend can be stated using: + +``` +make build-api && ./bin/perses --config="./dev/config-kubernetes.yaml" --log.level="debug" --web.listen-address=":8081" +``` + +The reverse-proxy can then be started using the following commands. The reverse-proxy is located at port 8080 so the frontend +development server can be run in its default state as described in the [UI Readme](../../ui/README.md). + +``` +export USER_TOKEN="$(kubectl --kubeconfig=./dev/kubernetes/local/kind-admin create token user --namespace perses --duration 8760h)" +caddy run --config ./dev/kubernetes/Caddyfile --adapter caddyfile +``` + +Perses should then be running on localhost:8080 logged in as "user". ## Debugging + If your backend fails to start due to being unable to there being `no such file or directory` for your kubeconfig you may need to provide full file paths for all file locations in the `config-kubernetes.yaml` file. diff --git a/docs/cli.md b/docs/cli.md index 652ae2e353..7cdf653a98 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -65,6 +65,7 @@ If the server requires an authentication, you will have to provide either: - a user + password: `--username` and `--password` can be used to set a username & password. The command will contact the Perses server with the credential(s). It will return a Bearer JWT token which expires after 1h. - external auth information: if the server relies on an external OIDC/OAuth provider for authentication, use `--client-id` and `--client-secret` to pass the client credentials, plus `--provider` to pass the identifier of the external provider (e.g `google`, `azure`..). +- kubeconfig file location: if the server relies on the delegated kubernetes provider for authentication, use `--kube` to login using a kubeconfig file. The `KUBECONFIG` env variable and fallback of `~/.kube/config` will be used unless `--kubeconfig-file` is used to set the path. The URL and the token will be stored in JSON file that is by default `/.perses/config.json`. diff --git a/internal/api/authorization/k8s/k8s.go b/internal/api/authorization/k8s/k8s.go index c94aab9443..d86e2e1fd9 100644 --- a/internal/api/authorization/k8s/k8s.go +++ b/internal/api/authorization/k8s/k8s.go @@ -209,7 +209,7 @@ func (k *k8sImpl) GetUserProjects(ctx echo.Context, _ v1Role.Action, _ v1Role.Sc k8sNamespaces := k.getNamespaceList() authorizedNamespaces := []string{} for _, k8sNamespace := range k8sNamespaces { - if k.checkNamespacePermission(ctx, k8sNamespace, kubernetesUser) { + if k.hasPermissionForNamespace(ctx, k8sNamespace, kubernetesUser) { authorizedNamespaces = append(authorizedNamespaces, k8sNamespace) } } @@ -245,25 +245,7 @@ func (k *k8sImpl) HasPermission(ctx echo.Context, requestAction v1Role.Action, r return false } - action := getK8sAction(requestAction) - apiGroup := getK8sAPIGroup(scope) - apiVersion := getK8sAPIVersion(scope) - - // Try checking the specific project for access - // If the namespace doesn't exist in k8s, the authorizer will return the "*" permissions - attributes := authorizer.AttributesRecord{ - User: kubernetesUser, - Verb: string(action), - Namespace: requestProject, - APIGroup: apiGroup, - APIVersion: apiVersion, - Resource: string(scope), - Subresource: "", - Name: "", - ResourceRequest: true, - } - - authorized, _, _ := k.authorizer.Authorize(ctx.Request().Context(), attributes) + authorized, _ := k.checkSpecificPermision(ctx, requestProject, kubernetesUser, requestScope, requestAction) return authorized == authorizer.DecisionAllow } @@ -299,135 +281,127 @@ func (k *k8sImpl) GetPermissions(ctx echo.Context) (map[string][]*v1Role.Permiss namespaces := k.getNamespaceList() - for _, k8sScope := range k8sScopesToCheck { - // Contains the actions which need to be checked every loop. - // If action is permitted at the global scope, then we don't need to check within the namespace scope. - // Since each permission check is a network round trip, it is best to optimize - // the logic to reduce the number of permission checks we make - actionsToCheck := []k8sAction{ - k8sWildcardAction, - k8sReadAction, - k8sCreateAction, - k8sUpdateAction, - k8sDeleteAction, + // Do an initial pass over the all namespace project so that we don't have to check the permissions available there + // against any of the projects we check after this point + allNamespacePermittedActions := map[v1Role.Scope][]v1Role.Action{} + allNamespacePermissions := []*v1Role.Permission{} + for _, scope := range scopesToCheck { + permittedActions := k.getPermittedActions(ctx, v1.WildcardProject, kubernetesUser, scope, []v1Role.Action{}) + if len(permittedActions) > 0 { + allNamespacePermittedActions[scope] = permittedActions + allNamespacePermissions = append(allNamespacePermissions, &v1Role.Permission{ + Scopes: []v1Role.Scope{scope}, + Actions: permittedActions, + }) } - apiGroup := getK8sAPIGroup(k8sScope) - apiVersion := getK8sAPIVersion(k8sScope) - project: - for _, k8sProject := range namespaces { - scopeActions, ok := userPermissions[k8sProject] - if ok { - scopeActions = append(scopeActions, &v1Role.Permission{ - Scopes: []v1Role.Scope{getPersesScope(k8sScope)}, - Actions: []v1Role.Action{}, - }) - } else { - scopeActions = []*v1Role.Permission{{ - Scopes: []v1Role.Scope{getPersesScope(k8sScope)}, - Actions: []v1Role.Action{}, - }} + } + if len(allNamespacePermissions) > 0 { + userPermissions[v1.WildcardProject] = allNamespacePermissions + } - } - permissionIndex := len(scopeActions) - 1 - - action: - for _, k8sActionToCheck := range actionsToCheck { - attributes := authorizer.AttributesRecord{ - User: kubernetesUser, - Verb: string(k8sActionToCheck), - Namespace: k8sProject, - APIGroup: apiGroup, - APIVersion: apiVersion, - Resource: string(k8sScope), - Subresource: "", - Name: "", - ResourceRequest: true, - } - - authorized, _, err := k.authorizer.Authorize(ctx.Request().Context(), attributes) - if err != nil { - // If the request errors, then assume the rest of the requests will also error and break - // out early - break project - } - - if k8sActionToCheck == k8sWildcardAction { - if authorized == authorizer.DecisionAllow { - scopeActions[permissionIndex].Actions = append(scopeActions[permissionIndex].Actions, getPersesAction(k8sWildcardAction)) - if k8sProject == v1.WildcardProject { - userPermissions[k8sProject] = scopeActions - break project - // User has all permissions for this scope, no need - // to check other namespaces or permissions - } - // User has all permissions for this namespace, no need - // to check other permissions - break action - } - } - - if k8sActionToCheck == k8sReadAction && authorized == authorizer.DecisionDeny { - // User can't even read the resource, no need to check the rest - continue project - } - - if authorized == authorizer.DecisionAllow && slices.Contains([]k8sAction{ - k8sReadAction, - k8sCreateAction, - k8sUpdateAction, - k8sDeleteAction, - }, k8sActionToCheck) { - scopeActions[permissionIndex].Actions = append(scopeActions[permissionIndex].Actions, getPersesAction(k8sActionToCheck)) - if k8sProject == v1.WildcardProject { - actionsToCheck = slices.DeleteFunc(actionsToCheck, func(actionToCheck k8sAction) bool { - return actionToCheck == k8sActionToCheck - }) - if len(actionsToCheck) == 1 { - // User has all permissions except for wildcard for this scope, no need - // to check the other namespaces or permissions - userPermissions[k8sProject] = scopeActions - break project - } - } - } - } - // Check if any actions are permitted for the user for the - // given project - if len(scopeActions[permissionIndex].Actions) > 0 { - userPermissions[k8sProject] = scopeActions - } + for _, namespace := range namespaces { + namespacePermissions := k.getNamespacePermissions(ctx, namespace, kubernetesUser, scopesToCheck, allNamespacePermittedActions) + if len(namespacePermissions) > 0 { + userPermissions[namespace] = namespacePermissions } } return userPermissions, nil } +func (k *k8sImpl) getNamespacePermissions(ctx echo.Context, namespace string, user user.Info, scopes []v1Role.Scope, knownPermissions map[v1Role.Scope][]v1Role.Action) []*v1Role.Permission { + namespacePermissions := []*v1Role.Permission{} + for _, scope := range scopes { + permittedActions := k.getPermittedActions(ctx, namespace, user, scope, knownPermissions[scope]) + if len(permittedActions) > 0 { + namespacePermissions = append(namespacePermissions, &v1Role.Permission{ + Scopes: []v1Role.Scope{scope}, + Actions: permittedActions, + }) + } + } + return namespacePermissions +} + +func (k *k8sImpl) getPermittedActions(ctx echo.Context, namespace string, user user.Info, scope v1Role.Scope, knownActions []v1Role.Action) []v1Role.Action { + // We only need to check actions which aren't already permitted + actionsToCheck := getUnknownActions(knownActions) + newlyValidActions := []v1Role.Action{} + for _, action := range actionsToCheck { + authorized, err := k.checkSpecificPermision(ctx, namespace, user, scope, action) + + if err != nil { + // If the request errors, then assume the rest of the requests will also error and break + // out early + return newlyValidActions + } + + if action == v1Role.WildcardAction && authorized == authorizer.DecisionAllow { + newlyValidActions = append(newlyValidActions, v1Role.WildcardAction) + return newlyValidActions + } + + if action == v1Role.ReadAction && authorized == authorizer.DecisionDeny { + // If the user cannot even read the scope then assume they won't have other access + return newlyValidActions + } + + if authorized == authorizer.DecisionAllow { + newlyValidActions = append(newlyValidActions, action) + } + } + return newlyValidActions +} + +func getUnknownActions(knownActions []v1Role.Action) []v1Role.Action { + // If all actions are permitted in then nothing is unknown + if len(knownActions) == 1 && knownActions[0] == v1Role.WildcardAction { + return []v1Role.Action{} + } + allActions := []v1Role.Action{ + v1Role.WildcardAction, + v1Role.ReadAction, + v1Role.CreateAction, + v1Role.UpdateAction, + v1Role.DeleteAction, + } + return slices.DeleteFunc(allActions, func(actionToCheck v1Role.Action) bool { + return slices.Contains(knownActions, actionToCheck) + }) +} + +func (k *k8sImpl) checkSpecificPermision(ctx echo.Context, namespace string, user user.Info, scope v1Role.Scope, action v1Role.Action) (authorized authorizer.Decision, err error) { + k8sScope := getK8sScope(scope) + apiGroup := getK8sAPIGroup(k8sScope) + apiVersion := getK8sAPIVersion(k8sScope) + attributes := authorizer.AttributesRecord{ + User: user, + Verb: string(getK8sAction(action)), + Namespace: namespace, + APIGroup: apiGroup, + APIVersion: apiVersion, + Resource: string(k8sScope), + Subresource: "", + Name: "", + ResourceRequest: true, + } + authorized, _, err = k.authorizer.Authorize(ctx.Request().Context(), attributes) + return authorized, err +} + // RefreshPermissions implements [Authorization] func (k *k8sImpl) RefreshPermissions() error { return nil } -func (k *k8sImpl) checkNamespacePermission(ctx echo.Context, namespace string, user user.Info) bool { +func (k *k8sImpl) hasPermissionForNamespace(ctx echo.Context, namespace string, user user.Info) bool { // Rather than checking if the user has access to the namespace, we check if the user has access - // to any of the perses scopes within the namespace, since namespaces which the user has access to + // to read any of the perses scopes within the namespace, since namespaces which the user has access to // but cannot view perses scopes are irrelevant - for _, k8sScope := range k8sScopesToCheck { - attributes := authorizer.AttributesRecord{ - User: user, - Verb: string(k8sReadAction), - Namespace: namespace, - APIGroup: "perses.dev", - APIVersion: "v1alpha1", - Resource: string(k8sScope), - Subresource: "", - Name: "", - ResourceRequest: true, - } - - // don't need to check bool or error since if the authorized isn't allow then all other instances - // mean failure - authorized, _, _ := k.authorizer.Authorize(ctx.Request().Context(), attributes) + for _, scope := range scopesToCheck { + authorized, _ := k.checkSpecificPermision(ctx, namespace, user, scope, v1Role.ReadAction) if authorized == authorizer.DecisionAllow { + // We can return early if the user can access any of the scopes return true } } diff --git a/internal/api/authorization/k8s/k8s_test.go b/internal/api/authorization/k8s/k8s_test.go index 8e06f6a629..57420f0845 100644 --- a/internal/api/authorization/k8s/k8s_test.go +++ b/internal/api/authorization/k8s/k8s_test.go @@ -175,6 +175,12 @@ func mockAuthentication(clientset *fake.Clientset) { Username: "user1", Groups: []string{"system:authenticated"}, } + case "user2-token": + tr.Status.Authenticated = true + tr.Status.User = authnv1.UserInfo{ + Username: "user2", + Groups: []string{"system:authenticated"}, + } default: tr.Status.Authenticated = false tr.Status.Error = "unknown token" @@ -204,6 +210,10 @@ func mockAuthorization(clientset *fake.Clientset) { } else if spec.User == "user1" { sar.Status.Allowed = false sar.Status.Reason = fmt.Sprintf("Mock RBAC: user1 cannot '%s'", createAction.GetVerb()) + } else if spec.User == "user2" && spec.ResourceAttributes.Verb == "get" { + sar.Status.Allowed = true + } else if spec.User == "user2" && spec.ResourceAttributes.Verb == "create" && spec.ResourceAttributes.Namespace == "project0" { + sar.Status.Allowed = true } else { sar.Status.Allowed = false sar.Status.Reason = "Mock RBAC: User does not exist" @@ -285,6 +295,38 @@ func TestHasPermission(t *testing.T) { reqScope: "Dashboard", expectedResult: false, }, + { + title: "user2 has read dashboard perm in project0", + user: "user2", + reqAction: "read", + reqProject: "project0", + reqScope: "Dashboard", + expectedResult: true, + }, + { + title: "user2 has read dashboard perm in project1", + user: "user2", + reqAction: "read", + reqProject: "project1", + reqScope: "Dashboard", + expectedResult: true, + }, + { + title: "user2 has create dashboard perm in project0", + user: "user2", + reqAction: "create", + reqProject: "project0", + reqScope: "Dashboard", + expectedResult: true, + }, + { + title: "user2 doesn't have create dashboard perm in project1", + user: "user2", + reqAction: "create", + reqProject: "project1", + reqScope: "Dashboard", + expectedResult: false, + }, } for i := range testSuites { test := testSuites[i] @@ -322,6 +364,11 @@ func TestGetUserProjects(t *testing.T) { user: "user0", expectedResult: []string{"project0"}, }, + { + title: "user2 has access to all projects", + user: "user2", + expectedResult: []string{"*", "perses", "project0", "project1"}, + }, } for i := range testSuites { test := testSuites[i] @@ -369,6 +416,19 @@ func TestGetPermissions(t *testing.T) { &v1Role.Permission{Actions: []v1Role.Action{"read"}, Scopes: []v1Role.Scope{"Datasource"}}, }}, }, + { + title: "user2 has read permissions in all namespaces and create permissions in project0", + user: "user2", + expectedResult: map[string][]*v1Role.Permission{"*": { + &v1Role.Permission{Actions: []v1Role.Action{"read"}, Scopes: []v1Role.Scope{"Dashboard"}}, + &v1Role.Permission{Actions: []v1Role.Action{"read"}, Scopes: []v1Role.Scope{"GlobalDatasource"}}, + &v1Role.Permission{Actions: []v1Role.Action{"read"}, Scopes: []v1Role.Scope{"Datasource"}}, + }, "project0": { + &v1Role.Permission{Actions: []v1Role.Action{"create"}, Scopes: []v1Role.Scope{"Dashboard"}}, + &v1Role.Permission{Actions: []v1Role.Action{"create"}, Scopes: []v1Role.Scope{"GlobalDatasource"}}, + &v1Role.Permission{Actions: []v1Role.Action{"create"}, Scopes: []v1Role.Scope{"Datasource"}}, + }}, + }, } for i := range testSuites { test := testSuites[i] diff --git a/internal/api/authorization/k8s/translation.go b/internal/api/authorization/k8s/translation.go index 7280b9908c..a94e588487 100644 --- a/internal/api/authorization/k8s/translation.go +++ b/internal/api/authorization/k8s/translation.go @@ -37,10 +37,10 @@ const ( k8sProjectScope k8sScope = "namespaces" ) -var k8sScopesToCheck = [3]k8sScope{ - k8sDashboardScope, - k8sGlobalDatasourceScope, - k8sDatasourceScope, +var scopesToCheck = []v1Role.Scope{ + v1Role.DashboardScope, + v1Role.GlobalDatasourceScope, + v1Role.DatasourceScope, } func getK8sAction(action v1Role.Action) k8sAction { @@ -60,23 +60,6 @@ func getK8sAction(action v1Role.Action) k8sAction { } } -func getPersesAction(action k8sAction) v1Role.Action { - switch action { - case k8sReadAction: - return v1Role.ReadAction - case k8sCreateAction: - return v1Role.CreateAction - case k8sUpdateAction: - return v1Role.UpdateAction - case k8sDeleteAction: - return v1Role.DeleteAction - case k8sWildcardAction: - return v1Role.WildcardAction - default: // not reachable - return "" - } -} - // GetScope parse string to Scope (not case-sensitive) func getK8sScope(scope v1Role.Scope) k8sScope { switch scope { @@ -91,24 +74,6 @@ func getK8sScope(scope v1Role.Scope) k8sScope { case v1Role.WildcardScope: return k8sWildcardScope default: - return "" // Non-K8s Scope, use guest permissions - } -} - -// GetScope parse string to Scope (not case-sensitive) -func getPersesScope(scope k8sScope) v1Role.Scope { - switch scope { - case k8sDashboardScope: - return v1Role.DashboardScope - case k8sGlobalDatasourceScope: - return v1Role.GlobalDatasourceScope - case k8sDatasourceScope: - return v1Role.DatasourceScope - case k8sProjectScope: - return v1Role.ProjectScope - case k8sWildcardScope: - return v1Role.WildcardScope - default: // not reachable - return "" + return "" // Scope doesn't have a k8s equivalent. For now default to rejecting } } diff --git a/internal/cli/cmd/whoami/whoami.go b/internal/cli/cmd/whoami/whoami.go index f1c6c4f466..94bf2653d9 100644 --- a/internal/cli/cmd/whoami/whoami.go +++ b/internal/cli/cmd/whoami/whoami.go @@ -37,6 +37,12 @@ type option struct { apiClient api.ClientInterface } +type k8sUser struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` +} + func (o *option) Complete(_ []string) error { o.authorization = config.Global.RestClientConfig.Authorization @@ -84,6 +90,18 @@ func (o *option) Whoami() (string, error) { return "", err } + // Because kubernetes usernames have less restrictions than perses ones they can contain characters which + // are considered invalid. Due to this, we unmarshall without performing validation on the username + if config.Global.RestClientConfig.K8sAuth != nil { + result := &k8sUser{} + err := res.Object(result) + if err != nil { + return "", err + } + + return result.Metadata.Name, nil + } + result := &v1.PublicUser{} err := res.Object(result) if err != nil { diff --git a/scripts/k8s-dev.sh b/scripts/k8s-dev.sh deleted file mode 100755 index 4ae7e2713e..0000000000 --- a/scripts/k8s-dev.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash - -SESSION="perses-dev" -SESSION_EXISTS=$(tmux list-sessions | grep $SESSION) - -if [ "$SESSION_EXISTS" != "" ]; then - # Attach to existing session - tmux attach -t $SESSION - exit 0 -fi - -USER_TOKEN=$(kubectl --kubeconfig=./dev/kubernetes/local/kind-admin create token user --namespace perses --duration 8760h) - -tmux new-session -d -s $SESSION - -# |----------|------------| -# | | backend | -# | frontend |------------| -# | | auth-proxy | -# |----------|------------| - -tmux send-keys -t $SESSION:0 'cd ui && npm run start' C-m - -tmux split-window -h -t $SESSION:0 -tmux send-keys -t $SESSION:0.1 'make build-api && ./bin/perses --config="./dev/config-kubernetes.yaml" --log.level="debug" --web.listen-address=":8081"' C-m - -tmux split-window -v -t $SESSION:0.1 -tmux send-keys -t $SESSION:0.2 "export USER_TOKEN='$USER_TOKEN'" C-m -tmux send-keys -t $SESSION:0.2 'caddy run --config ./dev/kubernetes/Caddyfile --adapter caddyfile' C-m - -tmux attach -t $SESSION diff --git a/ui/app/src/model/user-client.ts b/ui/app/src/model/user-client.ts index 0400026816..bb7772c5f3 100644 --- a/ui/app/src/model/user-client.ts +++ b/ui/app/src/model/user-client.ts @@ -39,11 +39,7 @@ function getUser(name: string): Promise { } function getCurrentUser(): Promise { - const url = buildURL({ resource: userResource, pathSuffix: ['me'] }); - return fetchJson(url, { - method: HTTPMethodGET, - headers: HTTPHeader, - }); + return getUser('me'); } function getUsers(): Promise {