Skip to content

Commit

Permalink
First working implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
prometherion committed Sep 17, 2020
1 parent 3a9c388 commit 3c69d06
Show file tree
Hide file tree
Showing 10 changed files with 1,390 additions and 1 deletion.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@

# Dependency directories (remove the comment below to include it)
# vendor/

.idea
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM golang:1.13 as builder
WORKDIR /workspace
COPY go.mod go.mod
COPY go.sum go.sum
RUN go mod download
COPY main.go main.go
COPY webserver webserver
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GO111MODULE=on go build -a -o capsule-ns-filter main.go

FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=builder /workspace/capsule-ns-filter .
USER nonroot:nonroot

ENTRYPOINT ["/capsule-ns-filter"]
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,50 @@
# capsule-ns-filter
PoC for Namespace aggregation for Tenant owners

PoC for Namespace aggregation for Tenant owners.

## The problem

This is an add-on for [Capsule](https://github.com/clastix/capsule), a
multi-tenant Kubernetes Operator that provides multi-tenancy in Kubernetes.

Tl;dr; the _Tenant Owner_ is not able to list their Namespace resources:

```
# kubectl get ns
Error from server (Forbidden): namespaces is forbidden: User "alice" cannot list resource "namespaces" in API group "" at the cluster scope
```

The reason, as the error message reported, is that the _list_ action is
available only at Cluster-Scope.

This project is a simple reverse proxy intercepting the Kubernetes
`api/v1/namespaces` endpoint in order to filter according to the Capsule
business logic only the available Namespace resources assigned to the
requester.

All other endpoints are proxied against the original Kubernetes API endpoint
using the same request, so no side-effects are expected.

## Installation

`capsule-ns-filter` doesn't need to have `cluster-admin` _RoleBinding_
although all read verbs (`GET`, `LIST`, `WATCH`) against the following
resources are mandatory:

- `namespaces`
- `tenants.capsule.clastix.io`

## FAQ

### Does it work with kubectl?

That's a feature we're working on.

<!-- TODO: track down feature with GH issues -->

### Does it work with OpenShift Console?

Actually, tested only with the _3.11_ release: with some hacks it can do the
job.

<!-- TODO: document with further details -->
22 changes: 22 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module github.com/clastix/capsule-ns-filter

go 1.13

require (
github.com/clastix/capsule v0.0.0-20200915134831-c75f773fc622
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/google/go-cmp v0.5.2 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/gorilla/websocket v1.4.0
github.com/imdario/mergo v0.3.11 // indirect
github.com/stretchr/testify v1.6.1 // indirect
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a // indirect
golang.org/x/net v0.0.0-20200904194848-62affa334b73 // indirect
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 // indirect
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
k8s.io/api v0.19.0-beta.2
k8s.io/apimachinery v0.19.0-beta.2
k8s.io/client-go v0.19.0-beta.2
k8s.io/utils v0.0.0-20200912215256-4140de9c8800 // indirect
sigs.k8s.io/controller-runtime v0.6.2
)
808 changes: 808 additions & 0 deletions go.sum

Large diffs are not rendered by default.

95 changes: 95 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package main

import (
"context"
"flag"
"fmt"
"os"

capsulev1alpha1 "github.com/clastix/capsule/api/v1alpha1"
"github.com/clastix/capsule/pkg/indexer/tenant"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"sigs.k8s.io/controller-runtime/pkg/manager"

"github.com/clastix/capsule-ns-filter/webserver"
)

var (
scheme = runtime.NewScheme()
log = ctrl.Log.WithName("main")
)

func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(capsulev1alpha1.AddToScheme(scheme))
}

func main() {
var err error
var mgr ctrl.Manager

fs := flag.NewFlagSet("filter", flag.ExitOnError)
listeningPort := fs.Uint("listening-port", 9001, "HTTP port the proxy listens to")
k8sControlPlaneUrl := fs.String("k8s-control-plane-url", "https://kubernetes.default.svc", "Kubernetes control plane URL")
capsuleUserGroup := fs.String("capsule-user-group", "clastix.capsule.io", "The Capsule User Group eligible to create Namespace for Tenant resources")
err = fs.Parse(os.Args[1:])

opts := zap.Options{}
opts.BindFlags(flag.CommandLine)
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))

if err != nil {
log.Error(err, "cannot parse flags")
os.Exit(1)
}

log.Info("---")
log.Info(fmt.Sprintf("Manager will listen to port %d", *listeningPort))
log.Info(fmt.Sprintf("Connecting to the Kubernete API Server listening on %s", *k8sControlPlaneUrl))
log.Info(fmt.Sprintf("The selected Capsule User Group is %s", *capsuleUserGroup))
log.Info("---")

log.Info("Creating the manager")
mgr, err = ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
HealthProbeBindAddress: ":8081",
})
if err != nil {
log.Error(err, "cannot create new Manager")
os.Exit(1)
}

log.Info("Creating the Field Indexer")
ow := tenant.OwnerReference{}
err = mgr.GetFieldIndexer().IndexField(context.TODO(), ow.Object(), ow.Field(), ow.Func())
if err != nil {
log.Error(err, "cannot create new Field Indexer")
os.Exit(1)
}

var r manager.Runnable
log.Info("Creating the NamespaceFilter runner")
r, err = webserver.NewKubeFilter(*listeningPort, *k8sControlPlaneUrl, *capsuleUserGroup, ctrl.GetConfigOrDie())
if err != nil {
log.Error(err, "cannot create NamespaceFilter runner")
os.Exit(1)
}

log.Info("Adding the NamespaceFilter runner to the Manager")
err = mgr.Add(r)
if err != nil {
log.Error(err, "cannot add NameSpaceFilter as Runnable")
os.Exit(1)
}

log.Info("Starting the Manager")
err = mgr.Start(ctrl.SetupSignalHandler())
if err != nil {
log.Error(err, "cannot start the Manager")
os.Exit(1)
}
}
19 changes: 19 additions & 0 deletions webserver/namespace_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package webserver

import (
"strings"
)

type NamespaceList []string

func (n NamespaceList) Len() int {
return len(n)
}

func (n NamespaceList) Less(i, j int) bool {
return strings.ToLower(n[i]) < strings.ToLower(n[j])
}

func (n NamespaceList) Swap(i, j int) {
n[i], n[j] = n[j], n[i]
}
57 changes: 57 additions & 0 deletions webserver/request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package webserver

import (
"fmt"
"net/http"
"strings"

"github.com/clastix/capsule/pkg/utils"
"github.com/dgrijalva/jwt-go"
)

type Request interface {
IsUserInGroup(groupName string) (bool, error)
GetUserName() (string, error)
}

type httpRequest struct {
*http.Request
}

func (h httpRequest) getJwtClaims() jwt.MapClaims {
parser := jwt.Parser{
SkipClaimsValidation: true,
}
token, _, err := parser.ParseUnverified(strings.Replace(h.Header.Get("Authorization"), "Bearer ", "", -1), jwt.MapClaims{})
if err != nil {
panic(err)
}

return token.Claims.(jwt.MapClaims)
}

func (h httpRequest) IsUserInGroup(groupName string) (bool, error) {
claims := h.getJwtClaims()
g, ok := claims["groups"]
if !ok {
return false, fmt.Errorf("missing groups claim in JWT")
}
var groups []string
for _, v := range g.([]interface{}) {
groups = append(groups, v.(string))
}
return utils.UserGroupList(groups).IsInCapsuleGroup(groupName), nil
}

func (h httpRequest) GetUserName() (string, error) {
claims := h.getJwtClaims()
username, ok := claims["preferred_username"]
if !ok {
return "", fmt.Errorf("missing groups claim in JWT")
}
return username.(string), nil
}

func NewHttpRequest(request *http.Request) Request {
return &httpRequest{Request: request}
}
Loading

0 comments on commit 3c69d06

Please sign in to comment.