Skip to content

Commit

Permalink
Merge pull request #7 from anguslees/master
Browse files Browse the repository at this point in the history
Updates - RBAC support
  • Loading branch information
anguslees authored Jun 8, 2017
2 parents 3b00ca0 + 2215080 commit 88cecd0
Show file tree
Hide file tree
Showing 10 changed files with 228 additions and 66 deletions.
5 changes: 4 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ after_script: set +e

after_success:
- |
if [ "$TRAVIS_OS_NAME" = linux -a "$TRAVIS_BRANCH" = master -a "$TRAVIS_PULL_REQUEST" = false ]; then
if [[ "$TRAVIS_OS_NAME" == linux && \
"$TRAVIS_BRANCH" == master && \
"$TRAVIS_PULL_REQUEST" == false && \
"$TRAVIS_GO_VERSION" =~ '^1\.8\.' ]]; then
docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" quay.io
docker tag $CONTROLLER_IMAGE ${CONTROLLER_IMAGE_NAME}:latest
docker push ${CONTROLLER_IMAGE_NAME}:latest
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ ksonnet-seal: $(GO_FILES)
%-static: $(GO_FILES)
CGO_ENABLED=0 $(GO) build -o $@ -installsuffix cgo $(GO_FLAGS) ./cmd/$*

docker/%: %-static
docker/controller: controller-static
cp $< $@

controller.image: docker/Dockerfile docker/controller
Expand Down
94 changes: 94 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# "Sealed Secrets" for Kubernetes

**Problem:** "I can manage all my K8s config in git, except Secrets."

**Solution:** Encrypt your Secret into a SealedSecret, which *is* safe
to store - even to a public repository. The SealedSecret can be
decrypted only by the controller running in the target cluster and
nobody else (not even the original author) is able to obtain the
original Secret from the SealedSecret.

## Installation

See https://github.com/ksonnet/sealed-secrets/releases for the latest
release.

```sh
# Install client-side tool into $GOPATH/bin
$ go get github.com/ksonnet/sealed-secrets/cmd/ksonnet-seal

# Install server-side controller into kube-system namespace (by default)
$ kubectl create -f https://github.com/ksonnet/sealed-secrets/releases/download/v0.0.1b/controller.yaml
```

`controller.yaml` will create the `SealedSecret` third-party-resource,
install the controller into `kube-system` namespace, create a service
account and necessary RBAC roles.

After a few moments, the controller will start, generate a key pair,
and be ready for operation. If it does not, check the controller
logs.

The key certificate (public key portion) is used for sealing secrets,
and needs to be installed wherever `ksonnet-seal` is going to be
used. The certificate is not secret information, although you need to
ensure you are using the correct file.

The certificate is printed to the controller log on startup, and can
also be retrieved directly from the underlying secret (the latter
requires access to the sealing secret, which is generally an
undesirable thing). (TODO: Improve this part)

```sh
# Fetch cluster-wide certificate used for encrypting.
# The certificate is also printed to the controller log on startup.
$ kubectl get secret -n kube-system sealed-secrets-key -o go-template='{{index .data "tls.crt"}}' | base64 -d > seal.crt
```

## Usage

```sh
# This is the important bit:
$ ksonnet-seal --cert seal.crt <mysecret.json >mysealedsecret.json

# mysealedsecret.json is safe to upload to github, post to twitter,
# etc. Eventually:
$ kubectl create -f mysealedsecret.json

# Profit!
$ kubectl get secret mysecret
```

Note the `SealedSecret` and `Secret` must have *the same namespace and
name*. This is a feature to prevent other users on the same cluster
from re-using your sealed secrets. Any labels, annotations, etc on
the original `Secret` are preserved, but not automatically reflected
in the `SealedSecret`.

## Details

This controller adds a new `SealedSecret` third-party resource. The
interesting part of which is a base64-encoded asymmetrically encrypted
`Secret`.

The controller looks for a cluster-wide private/public key pair on
startup, and generates a new key pair if not found. The key is
persisted in a regular `Secret` in the same namespace as the
controller. The public key portion of this (in the form of a
self-signed certificate) should be made publicly available to anyone
wanting to use `SealedSecret`s with this cluster.

During encryption, the original `Secret` is JSON-encoded and
symmetrically encrypted using AES-GCM with a randomly-generated
single-use session key. The session key is then asymmetrically
encrypted with the controller's public key using RSA-OAEP, and the
original `Secret`'s namespace/name as the OAEP input parameter (aka
label). The final output is: 2 byte encrypted session key length ||
encrypted session key || encrypted Secret.

Note that during decryption by the controller, the `SealedSecret`'s
namespace/name is used as the OAEP input parameter, ensuring that the
`SealedSecret` and `Secret` are tied to the same namespace and name.

The generated `Secret` is marked as "owned" by the `SealedSecret` and
will be garbage collected if the `SealedSecret` is deleted.
11 changes: 8 additions & 3 deletions apis/v1alpha1/sealedsecret.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,16 @@ func (s *SealedSecret) Unseal(codecs runtimeserializer.CodecFactory, rnd io.Read
secret.SetNamespace(smeta.GetNamespace())
secret.SetName(smeta.GetName())

// This is sometimes empty? Fine - we know what the answer is
// going to be anyway.
//gvk := s.GetObjectKind().GroupVersionKind()
gvk := SchemeGroupVersion.WithKind("SealedSecret")

// Refer back to owning SealedSecret
ownerRefs := []metav1.OwnerReference{
metav1.OwnerReference{
APIVersion: s.GetObjectKind().GroupVersionKind().GroupVersion().String(),
Kind: s.GetObjectKind().GroupVersionKind().Kind,
{
APIVersion: gvk.GroupVersion().String(),
Kind: gvk.Kind,
Name: smeta.GetName(),
UID: smeta.GetUID(),
Controller: &boolTrue,
Expand Down
4 changes: 2 additions & 2 deletions cmd/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func (c *Controller) Run(stopCh <-chan struct{}) {

go c.informer.Run(stopCh)

if !cache.WaitForCacheSync(stopCh, c.informer.HasSynced) {
if !cache.WaitForCacheSync(stopCh, c.HasSynced) {
utilruntime.HandleError(fmt.Errorf("Timed out waiting for caches to sync"))
return
}
Expand Down Expand Up @@ -156,7 +156,7 @@ func (c *Controller) processNextItem() bool {
// No error, reset the ratelimit counters
c.queue.Forget(key)
} else if c.queue.NumRequeues(key) < maxRetries {
log.Printf("Error updating %s: %v", key, err)
log.Printf("Error updating %s, will retry: %v", key, err)
c.queue.AddRateLimited(key)
} else {
// err != nil and too many retries
Expand Down
28 changes: 1 addition & 27 deletions cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,14 @@ import (
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/pkg/api"
"k8s.io/client-go/pkg/api/v1"
"k8s.io/client-go/pkg/apis/extensions/v1beta1"
"k8s.io/client-go/rest"
certUtil "k8s.io/client-go/util/cert"

ssv1alpha1 "github.com/ksonnet/sealed-secrets/apis/v1alpha1"
)

var (
keyName = flag.String("key-name", "seal-key", "Name of Secret containing public/private key.")
keyName = flag.String("key-name", "sealed-secrets-key", "Name of Secret containing public/private key.")
keySize = flag.Int("key-size", 4096, "Size of encryption key.")
validFor = flag.Duration("key-ttl", 10*365*24*time.Hour, "Duration that certificate is valid for.")
myCN = flag.String("my-cn", "", "CN to use in generated certificate.")
Expand All @@ -42,27 +41,6 @@ type controller struct {
clientset kubernetes.Interface
}

func createTPR(client kubernetes.Interface) error {
tpr := &v1beta1.ThirdPartyResource{
ObjectMeta: metav1.ObjectMeta{
Name: ssv1alpha1.SealedSecretName,
},
Versions: []v1beta1.APIVersion{
{Name: ssv1alpha1.SchemeGroupVersion.Version},
},
Description: "A sealed (encrypted) Secret",
}
result, err := client.ExtensionsV1beta1().ThirdPartyResources().Create(tpr)
if err != nil && errors.IsAlreadyExists(err) {
result, err = client.ExtensionsV1beta1().ThirdPartyResources().Update(tpr)
}
if err != nil {
return err
}
log.Printf("Created/updated ThirdPartyResource: %#v", result)
return nil
}

func readKey(client kubernetes.Interface, namespace, keyName string) (*rsa.PrivateKey, []*x509.Certificate, error) {
secret, err := client.Core().Secrets(namespace).Get(keyName, metav1.GetOptions{})
if err != nil {
Expand Down Expand Up @@ -215,10 +193,6 @@ func main2() error {

myNs := myNamespace()

if err = createTPR(clientset); err != nil {
return err
}

privKey, err := initKey(clientset, rand.Reader, *keySize, myNs, *keyName)
if err != nil {
return err
Expand Down
21 changes: 0 additions & 21 deletions cmd/controller/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,27 +33,6 @@ func testRand() io.Reader {
return mathrand.New(mathrand.NewSource(42))
}

func TestCreateTPR(t *testing.T) {
client := fake.NewSimpleClientset()
if err := createTPR(client); err != nil {
t.Errorf("createTPR() from empty failed with %v", err)
}

t.Logf("actions from empty: %v", client.Actions())

if !hasAction(client, "create", "thirdpartyresources") {
t.Errorf("createTPR() failed to create thirdpartyresource")
}

client.ClearActions()

if err := createTPR(client); err != nil {
t.Errorf("createTPR() with existing failed with %v", err)
}

t.Logf("actions with existing: %v", client.Actions())
}

func TestReadKey(t *testing.T) {
rand := testRand()

Expand Down
4 changes: 3 additions & 1 deletion cmd/ksonnet-seal/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import (
var (
// TODO: Fetch this automatically.
// TODO: Verify k8s server signature against cert in kube client config.
certFile = flag.String("cert", "", "Certificate / public key to use for encryption.")
certFile = flag.String("cert", "", "Certificate / public key to use for encryption.")

// TODO: Fetch default from regular kubectl config
defaultNamespace = flag.String("namespace", api.NamespaceDefault, "Default namespace to assume for Secret.")
)

Expand Down
104 changes: 103 additions & 1 deletion controller.jsonnet
Original file line number Diff line number Diff line change
@@ -1,8 +1,60 @@
local k = import "ksonnet.beta.1/k.libsonnet";
local util = import "ksonnet.beta.1/util.libsonnet";

local objectMeta = k.core.v1.objectMeta;
local deployment = k.apps.v1beta1.deployment;
local container = k.core.v1.container;
local serviceAccount = k.core.v1.serviceAccount;

local clusterRole(name, rules) = {
apiVersion: "rbac.authorization.k8s.io/v1beta1",
kind: "ClusterRole",
metadata: objectMeta.name(name),
rules: rules,
};

local role(name, namespace="default", rules) = {
apiVersion: "rbac.authorization.k8s.io/v1beta1",
kind: "Role",
metadata: objectMeta.name(name) + objectMeta.namespace(namespace),
rules: rules,
};

// eg: "apps/v1beta1" -> "apps"
local apiGroupFromGV(gv) = (
local group = std.splitLimit(gv, "/", 1)[0];
if group == "v1" then "" else group
);

local crossGroupRef(target) = {
kind: target.kind,
apiGroup: apiGroupFromGV(target.apiVersion),
name: target.metadata.name,
};

local clusterRoleBinding(name, role, subjects) = {
apiVersion: "rbac.authorization.k8s.io/v1beta1",
kind: "ClusterRoleBinding",
metadata: objectMeta.name(name),
subjects: [
crossGroupRef(s) +
(if std.objectHas(s.metadata, "namespace") then {namespace: s.metadata.namespace}
else {})
for s in subjects],
roleRef: crossGroupRef(role),
};

local roleBinding(name, namespace="default", role, subjects) = {
apiVersion: "rbac.authorization.k8s.io/v1beta1",
kind: "RoleBinding",
metadata: objectMeta.name(name) + objectMeta.namespace(namespace),
subjects: [
crossGroupRef(s) +
(if std.objectHas(s.metadata, "namespace") then {namespace: s.metadata.namespace}
else {})
for s in subjects],
roleRef: crossGroupRef(role),
};

local trim = function(str) (
if std.startsWith(str, " ") || std.startsWith(str, "\n") then
Expand All @@ -24,8 +76,58 @@ local controllerContainer =

local labels = {name: "sealed-secrets-controller"};

local tpr = {
apiVersion: "extensions/v1beta1",
kind: "ThirdPartyResource",
metadata: objectMeta.name("sealed-secret.ksonnet.io"),
versions: [{name: "v1alpha1"}],
description: "A sealed (encrypted) Secret",
};

local controllerAccount =
serviceAccount.default("sealed-secrets-controller", namespace);

local unsealerRole = clusterRole("secrets-unsealer", [
{
apiGroups: ["ksonnet.io"],
resources: ["sealedsecrets"],
verbs: ["get", "list", "watch"],
},
{
apiGroups: [""],
resources: ["secrets"],
verbs: ["create", "update", "delete"], // don't need get
},
]);

local sealKeyRole = role("sealed-secrets-key-admin", namespace, [
{
apiGroups: [""],
resources: ["secrets"],
resourceName: ["sealed-secrets-key"],
verbs: ["get"],
},
{
apiGroups: [""],
resources: ["secrets"],
// Can't limit create by resourceName, because there's no resource yet
verbs: ["create"],
},
]);

local binding = clusterRoleBinding("sealed-secrets-controller", unsealerRole, [controllerAccount]);
local binding = roleBinding("sealed-secrets-controller", namespace, sealKeyRole, [controllerAccount]);

local controllerDeployment =
deployment.default("sealed-secrets-controller", controllerContainer, namespace) +
deployment.mixin.podSpec.serviceAccountName(controllerAccount.metadata.name) +
{spec+: {template+: {metadata: {labels: labels}}}};

util.prune(controllerDeployment)
{
tpr: util.prune(tpr),
controller: util.prune(controllerDeployment),
account: util.prune(controllerAccount),
unsealerRole: unsealerRole,
unsealKeyRole: sealKeyRole,
binding: binding,
}
21 changes: 12 additions & 9 deletions examples/secret.jsonnet
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
local kube = import "kube.libsonnet";

{
mysecret: kube.Secret("mysecret") {
data_: {
foo: "bar",
},
},
}
local k = import "ksonnet.beta.1/k.libsonnet";
local util = import "ksonnet.beta.1/util.libsonnet";

local secret = k.core.v1.secret;

local namespace = "default";

// Here is my super-secret data
local data = {foo: std.base64("sekret")};

secret.default("mysecret", namespace) +
secret.data(data)

0 comments on commit 88cecd0

Please sign in to comment.