storage/kubernetes: allow arbitrary client IDs
Use a hash algorithm to match client IDs to Kubernetes object names. Because cryptographic hash algorithms produce sums larger than a Kubernetes name can fit, a non-cryptographic hash is used instead. Hash collisions are checked and result in errors.
This commit is contained in:
parent
99717cb56d
commit
d7a75c5b5d
4 changed files with 115 additions and 35 deletions
|
@ -4,10 +4,13 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/base32"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"hash/fnv"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net"
|
"net"
|
||||||
|
@ -31,6 +34,14 @@ type client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
namespace string
|
namespace string
|
||||||
|
|
||||||
|
// Hash function to map IDs (which could span a large range) to Kubernetes names.
|
||||||
|
// While this is not currently upgradable, it could be in the future.
|
||||||
|
//
|
||||||
|
// The default hash is a non-cryptographic hash, because cryptographic hashes
|
||||||
|
// always produce sums too long to fit into a Kubernetes name. Because of this,
|
||||||
|
// gets, updates, and deletes are _always_ checked for collisions.
|
||||||
|
hash func() hash.Hash
|
||||||
|
|
||||||
// API version of the oidc resources. For example "oidc.coreos.com". This is
|
// API version of the oidc resources. For example "oidc.coreos.com". This is
|
||||||
// currently not configurable, but could be in the future.
|
// currently not configurable, but could be in the future.
|
||||||
apiVersion string
|
apiVersion string
|
||||||
|
@ -40,6 +51,18 @@ type client struct {
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// idToName maps an arbitrary ID, such as an email or client ID to a Kubernetes object name.
|
||||||
|
func (c *client) idToName(s string) string {
|
||||||
|
return idToName(s, c.hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kubernetes names must match the regexp '[a-z0-9]([-a-z0-9]*[a-z0-9])?'.
|
||||||
|
var encoding = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567")
|
||||||
|
|
||||||
|
func idToName(s string, h func() hash.Hash) string {
|
||||||
|
return strings.TrimRight(encoding.EncodeToString(h().Sum([]byte(s))), "=")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *client) urlFor(apiVersion, namespace, resource, name string) string {
|
func (c *client) urlFor(apiVersion, namespace, resource, name string) string {
|
||||||
basePath := "apis/"
|
basePath := "apis/"
|
||||||
if apiVersion == "v1" {
|
if apiVersion == "v1" {
|
||||||
|
@ -277,6 +300,7 @@ func newClient(cluster k8sapi.Cluster, user k8sapi.AuthInfo, namespace string) (
|
||||||
return &client{
|
return &client{
|
||||||
client: &http.Client{Transport: t},
|
client: &http.Client{Transport: t},
|
||||||
baseURL: cluster.Server,
|
baseURL: cluster.Server,
|
||||||
|
hash: func() hash.Hash { return fnv.New64() },
|
||||||
namespace: namespace,
|
namespace: namespace,
|
||||||
apiVersion: "oidc.coreos.com/v1",
|
apiVersion: "oidc.coreos.com/v1",
|
||||||
}, nil
|
}, nil
|
||||||
|
|
|
@ -1,6 +1,33 @@
|
||||||
package kubernetes
|
package kubernetes
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"hash"
|
||||||
|
"hash/fnv"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This test does not have an explicit error condition but is used
|
||||||
|
// with the race detector to detect the safety of idToName.
|
||||||
|
func TestIDToName(t *testing.T) {
|
||||||
|
n := 100
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(n)
|
||||||
|
c := make(chan struct{})
|
||||||
|
|
||||||
|
h := func() hash.Hash { return fnv.New64() }
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
go func() {
|
||||||
|
<-c
|
||||||
|
name := idToName("foo", h)
|
||||||
|
_ = name
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(c)
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
func TestNamespaceFromServiceAccountJWT(t *testing.T) {
|
func TestNamespaceFromServiceAccountJWT(t *testing.T) {
|
||||||
namespace, err := namespaceFromServiceAccountJWT(serviceAccountToken)
|
namespace, err := namespaceFromServiceAccountJWT(serviceAccountToken)
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/net/context"
|
"golang.org/x/net/context"
|
||||||
|
@ -187,21 +188,47 @@ func (cli *client) GetAuthCode(id string) (storage.AuthCode, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *client) GetClient(id string) (storage.Client, error) {
|
func (cli *client) GetClient(id string) (storage.Client, error) {
|
||||||
var c Client
|
c, err := cli.getClient(id)
|
||||||
if err := cli.get(resourceClient, id, &c); err != nil {
|
if err != nil {
|
||||||
return storage.Client{}, err
|
return storage.Client{}, err
|
||||||
}
|
}
|
||||||
return toStorageClient(c), nil
|
return toStorageClient(c), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cli *client) getClient(id string) (Client, error) {
|
||||||
|
var c Client
|
||||||
|
name := cli.idToName(id)
|
||||||
|
if err := cli.get(resourceClient, name, &c); err != nil {
|
||||||
|
return Client{}, err
|
||||||
|
}
|
||||||
|
if c.ID != id {
|
||||||
|
return Client{}, fmt.Errorf("get client: ID %q mapped to client with ID %q", id, c.ID)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (cli *client) GetPassword(email string) (storage.Password, error) {
|
func (cli *client) GetPassword(email string) (storage.Password, error) {
|
||||||
var p Password
|
p, err := cli.getPassword(email)
|
||||||
if err := cli.get(resourcePassword, emailToID(email), &p); err != nil {
|
if err != nil {
|
||||||
return storage.Password{}, err
|
return storage.Password{}, err
|
||||||
}
|
}
|
||||||
return toStoragePassword(p), nil
|
return toStoragePassword(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cli *client) getPassword(email string) (Password, error) {
|
||||||
|
// TODO(ericchiang): Figure out whose job it is to lowercase emails.
|
||||||
|
email = strings.ToLower(email)
|
||||||
|
var p Password
|
||||||
|
name := cli.idToName(email)
|
||||||
|
if err := cli.get(resourcePassword, name, &p); err != nil {
|
||||||
|
return Password{}, err
|
||||||
|
}
|
||||||
|
if email != p.Email {
|
||||||
|
return Password{}, fmt.Errorf("get email: email %q mapped to password with email %q", email, p.Email)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (cli *client) GetKeys() (storage.Keys, error) {
|
func (cli *client) GetKeys() (storage.Keys, error) {
|
||||||
var keys Keys
|
var keys Keys
|
||||||
if err := cli.get(resourceKeys, keysName, &keys); err != nil {
|
if err := cli.get(resourceKeys, keysName, &keys); err != nil {
|
||||||
|
@ -242,7 +269,12 @@ func (cli *client) DeleteAuthCode(code string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *client) DeleteClient(id string) error {
|
func (cli *client) DeleteClient(id string) error {
|
||||||
return cli.delete(resourceClient, id)
|
// Check for hash collition.
|
||||||
|
c, err := cli.getClient(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return cli.delete(resourceClient, c.ObjectMeta.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *client) DeleteRefresh(id string) error {
|
func (cli *client) DeleteRefresh(id string) error {
|
||||||
|
@ -250,28 +282,34 @@ func (cli *client) DeleteRefresh(id string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *client) DeletePassword(email string) error {
|
func (cli *client) DeletePassword(email string) error {
|
||||||
return cli.delete(resourcePassword, emailToID(email))
|
// Check for hash collition.
|
||||||
|
p, err := cli.getPassword(email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return cli.delete(resourcePassword, p.ObjectMeta.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *client) UpdateClient(id string, updater func(old storage.Client) (storage.Client, error)) error {
|
func (cli *client) UpdateClient(id string, updater func(old storage.Client) (storage.Client, error)) error {
|
||||||
var c Client
|
c, err := cli.getClient(id)
|
||||||
if err := cli.get(resourceClient, id, &c); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
updated, err := updater(toStorageClient(c))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updated, err := updater(toStorageClient(c))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updated.ID = c.ID
|
||||||
|
|
||||||
newClient := cli.fromStorageClient(updated)
|
newClient := cli.fromStorageClient(updated)
|
||||||
newClient.ObjectMeta = c.ObjectMeta
|
newClient.ObjectMeta = c.ObjectMeta
|
||||||
return cli.put(resourceClient, id, newClient)
|
return cli.put(resourceClient, c.ObjectMeta.Name, newClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *client) UpdatePassword(email string, updater func(old storage.Password) (storage.Password, error)) error {
|
func (cli *client) UpdatePassword(email string, updater func(old storage.Password) (storage.Password, error)) error {
|
||||||
id := emailToID(email)
|
p, err := cli.getPassword(email)
|
||||||
var p Password
|
if err != nil {
|
||||||
if err := cli.get(resourcePassword, id, &p); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -279,10 +317,11 @@ func (cli *client) UpdatePassword(email string, updater func(old storage.Passwor
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
updated.Email = p.Email
|
||||||
|
|
||||||
newPassword := cli.fromStoragePassword(updated)
|
newPassword := cli.fromStoragePassword(updated)
|
||||||
newPassword.ObjectMeta = p.ObjectMeta
|
newPassword.ObjectMeta = p.ObjectMeta
|
||||||
return cli.put(resourcePassword, id, newPassword)
|
return cli.put(resourcePassword, p.ObjectMeta.Name, newPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *client) UpdateKeys(updater func(old storage.Keys) (storage.Keys, error)) error {
|
func (cli *client) UpdateKeys(updater func(old storage.Keys) (storage.Keys, error)) error {
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package kubernetes
|
package kubernetes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base32"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -75,13 +74,14 @@ const keysName = "openid-connect-keys"
|
||||||
|
|
||||||
// Client is a mirrored struct from storage with JSON struct tags and
|
// Client is a mirrored struct from storage with JSON struct tags and
|
||||||
// Kubernetes type metadata.
|
// Kubernetes type metadata.
|
||||||
//
|
|
||||||
// TODO(ericchiang): Kubernetes has an extremely restricted set of characters it can use for IDs.
|
|
||||||
// Consider base32ing client IDs.
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
// Name is a hash of the ID.
|
||||||
k8sapi.TypeMeta `json:",inline"`
|
k8sapi.TypeMeta `json:",inline"`
|
||||||
k8sapi.ObjectMeta `json:"metadata,omitempty"`
|
k8sapi.ObjectMeta `json:"metadata,omitempty"`
|
||||||
|
|
||||||
|
// ID is immutable, since it's a primary key and should not be changed.
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
|
||||||
Secret string `json:"secret,omitempty"`
|
Secret string `json:"secret,omitempty"`
|
||||||
RedirectURIs []string `json:"redirectURIs,omitempty"`
|
RedirectURIs []string `json:"redirectURIs,omitempty"`
|
||||||
TrustedPeers []string `json:"trustedPeers,omitempty"`
|
TrustedPeers []string `json:"trustedPeers,omitempty"`
|
||||||
|
@ -106,9 +106,10 @@ func (cli *client) fromStorageClient(c storage.Client) Client {
|
||||||
APIVersion: cli.apiVersion,
|
APIVersion: cli.apiVersion,
|
||||||
},
|
},
|
||||||
ObjectMeta: k8sapi.ObjectMeta{
|
ObjectMeta: k8sapi.ObjectMeta{
|
||||||
Name: c.ID,
|
Name: cli.idToName(c.ID),
|
||||||
Namespace: cli.namespace,
|
Namespace: cli.namespace,
|
||||||
},
|
},
|
||||||
|
ID: c.ID,
|
||||||
Secret: c.Secret,
|
Secret: c.Secret,
|
||||||
RedirectURIs: c.RedirectURIs,
|
RedirectURIs: c.RedirectURIs,
|
||||||
TrustedPeers: c.TrustedPeers,
|
TrustedPeers: c.TrustedPeers,
|
||||||
|
@ -120,7 +121,7 @@ func (cli *client) fromStorageClient(c storage.Client) Client {
|
||||||
|
|
||||||
func toStorageClient(c Client) storage.Client {
|
func toStorageClient(c Client) storage.Client {
|
||||||
return storage.Client{
|
return storage.Client{
|
||||||
ID: c.ObjectMeta.Name,
|
ID: c.ID,
|
||||||
Secret: c.Secret,
|
Secret: c.Secret,
|
||||||
RedirectURIs: c.RedirectURIs,
|
RedirectURIs: c.RedirectURIs,
|
||||||
TrustedPeers: c.TrustedPeers,
|
TrustedPeers: c.TrustedPeers,
|
||||||
|
@ -258,17 +259,6 @@ type Password struct {
|
||||||
UserID string `json:"userID,omitempty"`
|
UserID string `json:"userID,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kubernetes only allows lower case letters for names.
|
|
||||||
//
|
|
||||||
// NOTE(ericchiang): This is currently copied from the storage package's NewID()
|
|
||||||
// method. Once we refactor those into the storage, just use that instead.
|
|
||||||
var encoding = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567")
|
|
||||||
|
|
||||||
// Map an arbitrary email to a valid Kuberntes name.
|
|
||||||
func emailToID(email string) string {
|
|
||||||
return strings.TrimRight(encoding.EncodeToString([]byte(strings.ToLower(email))), "=")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cli *client) fromStoragePassword(p storage.Password) Password {
|
func (cli *client) fromStoragePassword(p storage.Password) Password {
|
||||||
email := strings.ToLower(p.Email)
|
email := strings.ToLower(p.Email)
|
||||||
return Password{
|
return Password{
|
||||||
|
@ -277,7 +267,7 @@ func (cli *client) fromStoragePassword(p storage.Password) Password {
|
||||||
APIVersion: cli.apiVersion,
|
APIVersion: cli.apiVersion,
|
||||||
},
|
},
|
||||||
ObjectMeta: k8sapi.ObjectMeta{
|
ObjectMeta: k8sapi.ObjectMeta{
|
||||||
Name: emailToID(email),
|
Name: cli.idToName(email),
|
||||||
Namespace: cli.namespace,
|
Namespace: cli.namespace,
|
||||||
},
|
},
|
||||||
Email: email,
|
Email: email,
|
||||||
|
|
Reference in a new issue