Skip to content

Commit

Permalink
add ingress basic-auth secret generator (#26)
Browse files Browse the repository at this point in the history
* add ingress basic-auth secret generator

* add docs for basic-auth generator

* fix ci

* fix copy-and-paste
  • Loading branch information
hensur authored Aug 27, 2020
1 parent cf0e8b2 commit 6ecb769
Show file tree
Hide file tree
Showing 8 changed files with 287 additions and 13 deletions.
18 changes: 9 additions & 9 deletions .github/workflows/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@ on:

env:
KUBECONFIG: /tmp/kubeconfig
OPERATOR_SDK_VERSION: v0.16.0
OPERATOR_SDK_VERSION: v0.18.2
IMAGE_NAME: quay.io/mittwald/kubernetes-secret-generator

jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.13
- name: Set up Go 1.15
uses: actions/setup-go@v1
with:
go-version: 1.13
go-version: 1.15
id: go

- name: Check out code into the Go module directory
Expand Down Expand Up @@ -59,10 +59,10 @@ jobs:
name: Build Image
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.13
- name: Set up Go 1.15
uses: actions/setup-go@v1
with:
go-version: 1.13
go-version: 1.15
id: go

- name: Check out code into the Go module directory
Expand All @@ -80,10 +80,10 @@ jobs:
needs: ['test', 'build']
if: github.ref == 'refs/heads/master'
steps:
- name: Set up Go 1.13
- name: Set up Go 1.15
uses: actions/setup-go@v1
with:
go-version: 1.13
go-version: 1.15
id: go

- name: Registry Login
Expand All @@ -107,10 +107,10 @@ jobs:
needs: ['test', 'build']
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Set up Go 1.13
- name: Set up Go 1.15
uses: actions/setup-go@v1
with:
go-version: 1.13
go-version: 1.15
id: go

- name: Registry Login
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ fmt:
.PHONY: kind
kind: ## Create a kind cluster to test against
kind create cluster --name kind-k8s-secret-generator
kind get kubeconfig --internal --name kind-k8s-secret-generator | tee ${KUBECONFIG}
kind get kubeconfig --name kind-k8s-secret-generator | tee ${KUBECONFIG}

.PHONY: build
build:
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,41 @@ data:
ssh-privatekey: LS0tLS1CRUdJTi...
```

### Ingress Basic Auth

To generate Ingress Basic Auth credentials, the `secret-generator.v1.mittwald.de/type` annotation **has** to be present on the kubernetes secret object.

The operator will then add three keys to the secret object.
The ingress will interpret the `auth` key as a htpasswd entry. This entry contains the username, and the hashed generated password for the user.
The operator also stores the username and cleartext password in the `username` and `password` keys.

If a username other than `admin` is desired, it can be specified using the `secret-generator.v1.mittwald.de/basic-auth-username` annotation.

```yaml
apiVersion: v1
kind: Secret
metadata:
annotations:
secret-generator.v1.mittwald.de/type: basic-auth
data: {}
```

after reconciliation:

```yaml
apiVersion: v1
kind: Secret
metadata:
annotations:
secret-generator.v1.mittwald.de/type: basic-auth
secret-generator.v1.mittwald.de/autogenerate-generated-at: "2020-04-03T14:07:47+02:00"
type: Opaque
data:
username: admin
password: test123
auth: "admin:PASSWORD_HASH"
```

## Operational tasks

- Regenerate all automatically generated secrets:
Expand Down
58 changes: 58 additions & 0 deletions pkg/controller/secret/secret_basic_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package secret

import (
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"time"

"golang.org/x/crypto/bcrypt"
)

// Ingress basic auth secret field
const SecretFieldBasicAuthIngress = "auth"
const SecretFieldBasicAuthUsername = "username"
const SecretFieldBasicAuthPassword = "password"

type BasicAuthGenerator struct {
log logr.Logger
}

func (bg BasicAuthGenerator) generateData(instance *corev1.Secret) (reconcile.Result, error) {
existingAuth := string(instance.Data[SecretFieldBasicAuthIngress])

regenerate := instance.Annotations[AnnotationSecretRegenerate] != ""

if len(existingAuth) > 0 && !regenerate {
return reconcile.Result{}, nil
}
delete(instance.Annotations, AnnotationSecretRegenerate)

// if no username is given, fall back to "admin"
username := instance.Annotations[AnnotationBasicAuthUsername]
if username == "" {
username = "admin"
}

length, err := secretLengthFromAnnotation(secretLength(), instance.Annotations)
if err != nil {
return reconcile.Result{}, err
}

password, err := generateRandomString(length)
if err != nil {
bg.log.Error(err, "could not generate new random string")
return reconcile.Result{RequeueAfter: time.Second * 30}, err
}

passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
bg.log.Error(err, "could not hash random string")
return reconcile.Result{RequeueAfter: time.Second * 30}, err
}

instance.Data[SecretFieldBasicAuthIngress] = append([]byte(username+":"), passwordHash...)
instance.Data[SecretFieldBasicAuthUsername] = []byte(username)
instance.Data[SecretFieldBasicAuthPassword] = []byte(password)
return reconcile.Result{}, nil
}
174 changes: 174 additions & 0 deletions pkg/controller/secret/secret_basic_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package secret

import (
"context"
"github.com/imdario/mergo"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"strings"
"testing"
)

func newBasicAuthTestSecret(extraAnnotations map[string]string) *corev1.Secret {
annotations := map[string]string{
AnnotationSecretType: string(SecretTypeBasicAuth),
}

if extraAnnotations != nil {
if err := mergo.Merge(&annotations, extraAnnotations, mergo.WithOverride); err != nil {
panic(err)
}
}

s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: getSecretName(),
Namespace: "default",
Labels: map[string]string{
labelSecretGeneratorTest: "yes",
},
Annotations: annotations,
},
Type: corev1.SecretTypeOpaque,
Data: map[string][]byte{},
}

return s
}

// verify basic fields of the secret are present
func verifyBasicAuthSecret(t *testing.T, in, out *corev1.Secret) {
if out.Annotations[AnnotationSecretType] != string(SecretTypeBasicAuth) {
t.Errorf("generated secret has wrong type %s on %s annotation", out.Annotations[AnnotationSecretType], AnnotationSecretType)
}

_, wasGenerated := in.Annotations[AnnotationSecretAutoGeneratedAt]

auth := out.Data[SecretFieldBasicAuthIngress]
password := out.Data[SecretFieldBasicAuthPassword]

// check if password has been saved in clear text
// and has correct length (if the secret has actually been generated)
if !wasGenerated && (len(password) == 0 || len(password) != desiredLength(in)) {
t.Errorf("generated field has wrong length of %d", len(password))
}

// check if auth field has been generated (with separator)
if len(auth) == 0 || !strings.Contains(string(auth), ":") {
t.Errorf("auth field has wrong or no values %s", string(auth))
}

if _, ok := out.Annotations[AnnotationSecretAutoGeneratedAt]; !ok {
t.Errorf("secret has no %s annotation", AnnotationSecretAutoGeneratedAt)
}
}

func TestGenerateBasicAuthWithoutUsername(t *testing.T) {
in := newBasicAuthTestSecret(map[string]string{})
require.NoError(t, mgr.GetClient().Create(context.TODO(), in))

doReconcile(t, in, false)

out := &corev1.Secret{}
require.NoError(t, mgr.GetClient().Get(context.TODO(), client.ObjectKey{
Name: in.Name,
Namespace: in.Namespace}, out))

verifyBasicAuthSecret(t, in, out)
require.Equal(t, "admin", string(out.Data[SecretFieldBasicAuthUsername]))
}

func TestGenerateBasicAuthWithUsername(t *testing.T) {
in := newBasicAuthTestSecret(map[string]string{
AnnotationBasicAuthUsername: "test123",
})
require.NoError(t, mgr.GetClient().Create(context.TODO(), in))

doReconcile(t, in, false)

out := &corev1.Secret{}
require.NoError(t, mgr.GetClient().Get(context.TODO(), client.ObjectKey{
Name: in.Name,
Namespace: in.Namespace}, out))

verifyBasicAuthSecret(t, in, out)
require.Equal(t, "test123", string(out.Data[SecretFieldBasicAuthUsername]))
}

func TestGenerateBasicAuthRegenerate(t *testing.T) {
in := newBasicAuthTestSecret(map[string]string{
AnnotationBasicAuthUsername: "test123",
})
require.NoError(t, mgr.GetClient().Create(context.TODO(), in))

doReconcile(t, in, false)

out := &corev1.Secret{}
require.NoError(t, mgr.GetClient().Get(context.TODO(), client.ObjectKey{
Name: in.Name,
Namespace: in.Namespace}, out))

verifyBasicAuthSecret(t, in, out)
require.Equal(t, "test123", string(out.Data[SecretFieldBasicAuthUsername]))
oldPassword := string(out.Data[SecretFieldBasicAuthPassword])
oldAuth := string(out.Data[SecretFieldBasicAuthIngress])

// force regenerate
out.Annotations[AnnotationSecretRegenerate] = "yes"
require.NoError(t, mgr.GetClient().Update(context.TODO(), out))

doReconcile(t, out, false)

outNew := &corev1.Secret{}
require.NoError(t, mgr.GetClient().Get(context.TODO(), client.ObjectKey{
Name: in.Name,
Namespace: in.Namespace}, outNew))
newPassword := string(outNew.Data[SecretFieldBasicAuthPassword])
newAuth := string(outNew.Data[SecretFieldBasicAuthIngress])

if oldPassword == newPassword {
t.Errorf("secret has not been updated")
}

if oldAuth == newAuth {
t.Errorf("secret has not been updated")
}
}

func TestGenerateBasicAuthNoRegenerate(t *testing.T) {
in := newBasicAuthTestSecret(map[string]string{
AnnotationBasicAuthUsername: "test123",
})
require.NoError(t, mgr.GetClient().Create(context.TODO(), in))

doReconcile(t, in, false)

out := &corev1.Secret{}
require.NoError(t, mgr.GetClient().Get(context.TODO(), client.ObjectKey{
Name: in.Name,
Namespace: in.Namespace}, out))

verifyBasicAuthSecret(t, in, out)
require.Equal(t, "test123", string(out.Data[SecretFieldBasicAuthUsername]))
oldPassword := string(out.Data[SecretFieldBasicAuthPassword])
oldAuth := string(out.Data[SecretFieldBasicAuthIngress])

doReconcile(t, in, false)

outNew := &corev1.Secret{}
require.NoError(t, mgr.GetClient().Get(context.TODO(), client.ObjectKey{
Name: in.Name,
Namespace: in.Namespace}, outNew))
newPassword := string(out.Data[SecretFieldBasicAuthPassword])
newAuth := string(out.Data[SecretFieldBasicAuthIngress])

if oldPassword != newPassword {
t.Errorf("secret has been updated")
}

if oldAuth != newAuth {
t.Errorf("secret has been updated")
}
}
4 changes: 4 additions & 0 deletions pkg/controller/secret/secret_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ func (r *ReconcileSecret) Reconcile(request reconcile.Request) (reconcile.Result
generator = StringGenerator{
log: reqLogger.WithValues("type", SecretTypeString),
}
case SecretTypeBasicAuth:
generator = BasicAuthGenerator{
log: reqLogger.WithValues("type", SecretTypeBasicAuth),
}
}

res, err := generator.generateData(desired)
Expand Down
4 changes: 2 additions & 2 deletions pkg/controller/secret/secret_string.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type StringGenerator struct {
}

func (pg StringGenerator) generateData(instance *corev1.Secret) (reconcile.Result, error) {
toGenerate := instance.Annotations[AnnotationSecretAutoGenerate] // won't generate anything if annotation is not set
toGenerate := instance.Annotations[AnnotationSecretAutoGenerate]

genKeys := strings.Split(toGenerate, ",")

Expand Down Expand Up @@ -55,7 +55,7 @@ func (pg StringGenerator) generateData(instance *corev1.Secret) (reconcile.Resul

value, err := generateRandomString(length)
if err != nil {
pg.log.Error(err, "could not generate new instance")
pg.log.Error(err, "could not generate new random string")
return reconcile.Result{RequeueAfter: time.Second * 30}, err
}

Expand Down
5 changes: 4 additions & 1 deletion pkg/controller/secret/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,22 @@ const (
AnnotationSecretSecure = "secret-generator.v1.mittwald.de/secure"
AnnotationSecretType = "secret-generator.v1.mittwald.de/type"
AnnotationSecretLength = "secret-generator.v1.mittwald.de/length"
AnnotationBasicAuthUsername = "secret-generator.v1.mittwald.de/basic-auth-username"
)

type SecretType string

const (
SecretTypeString SecretType = "string"
SecretTypeSSHKeypair SecretType = "ssh-keypair"
SecretTypeBasicAuth SecretType = "basic-auth"
)

func (st SecretType) Validate() error {
switch st {
case SecretTypeString,
SecretTypeSSHKeypair:
SecretTypeSSHKeypair,
SecretTypeBasicAuth:
return nil
}
return fmt.Errorf("%s is not a valid secret type", st)
Expand Down

0 comments on commit 6ecb769

Please sign in to comment.