~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
481 lines · 13.1 KB Raw History
package vaultlayout

import (
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/identitybundle"
)

type VaultName string

const (
	Root    VaultName = "root"
	Cluster VaultName = "cluster"
)

type MountKind string

const (
	MountKV  MountKind = "kv-v2"
	MountPKI MountKind = "pki"
)

const (
	MountKVRoot    = "secret/"
	MountPKIRoot   = "pki/"
	MountKVCluster = "secret/"
)

const (
	PathKCPAdmin           = "secret/data/socialweb/kcp/admin"
	PathKCPAdminTokenStore = "secret/data/socialweb/kcp/admin-token-store"
	PathKCPClientCA        = "secret/data/socialweb/kcp/client-ca"
	PathContainerTLS       = "secret/data/socialweb/container/tls"

	PathClusterIdentity      = "secret/data/k3s/identity"
	PathClusterInstanceToken = "secret/data/k3s/instance-token"
	PathClusterPostgres      = "secret/data/k3s/postgres"
	PathClusterSSH           = "secret/data/k3s/ssh"

	PathDoorClaimsPrefix = "secret/data/socialweb-door/claims/"
)

const DoorClaimSecretsIngressKey = "secrets-ingress"

func DoorClaimSecretsIngressPath(claim string) string {
	return PathDoorClaimsPrefix + claim + "/" + DoorClaimSecretsIngressKey
}

const (
	RootServerName   = "root-vault"
	RootClient       = "gateway"
	PolicyRootWriter = "root-writer"
)

const (
	PolicyKCPRuntime         = "kcp-runtime"
	PolicyKCPAdminTokenStore = "kcp-admin-token-store"
	PolicyKCPCA              = "kcp-ca"
	PolicyContainerTLS       = "container-tls"
	PolicyKCPClientIssue     = "kcp-client-issue"
	PolicyIdentityBundle     = "identity-bundle"
	PolicyInstanceToken      = "instance-token"
	PolicyPostgres           = "postgres"
	PolicySSH                = "ssh"
)

const (
	RoleGateway         = "gateway"
	RoleClusterProvider = "clusterprovider"
	RoleContainerTLS    = "container-tls"
	RoleClusterIdentity = "cluster-identity"
	RoleClusterData     = "cluster-data"
	RoleClusterNode     = "cluster-node"
	RoleKCPClient       = "kcp-client"
)

type SecretClassName string

const (
	ClassKCPAdminKubeconfig SecretClassName = "kcp-admin-kubeconfig"
	ClassKCPAdminTokenStore SecretClassName = "kcp-admin-token-store"
	ClassKCPClientCA        SecretClassName = "kcp-client-ca"
	ClassContainerTLS       SecretClassName = "container-tls-certificate"
	ClassIdentityBundle     SecretClassName = "identity-bundle"
	ClassInstanceToken      SecretClassName = "instance-token"
	ClassPostgresCredential SecretClassName = "postgres-credential"
	ClassPerClusterSSHKey   SecretClassName = "per-cluster-ssh-key"
)

type Mount struct {
	Vault       VaultName
	Path        string
	Kind        MountKind
	MaxLeaseTTL time.Duration
}

type Policy struct {
	Vault VaultName
	Name  string
	HCL   string
}

type AppRole struct {
	Vault       VaultName
	Name        string
	Policies    []string
	TokenTTL    time.Duration
	TokenMaxTTL time.Duration
	SecretIDTTL time.Duration
}

type PKIRole struct {
	Vault            VaultName
	Mount            string
	Name             string
	AllowedDomains   []string
	AllowSubdomains  bool
	AllowBareDomains bool
	AllowIPSANs      bool
	EnforceHostnames bool
	RequireCN        bool
	ClientAuth       bool
	ServerAuth       bool
	KeyUsage         []string
	KeyType          string
	KeyBits          int
	TTL              time.Duration
	MaxTTL           time.Duration
}

type SecretClass struct {
	Name  SecretClassName
	Vault VaultName
}

type Secret struct {
	Vault    VaultName
	Path     string
	Class    SecretClassName
	Consumer string
	Keys     []string
}

type Layout struct {
	Mounts        []Mount
	Policies      []Policy
	AppRoles      []AppRole
	PKIRoles      []PKIRole
	Secrets       []Secret
	SecretClasses []SecretClass
}

func readPolicy(paths ...string) string {
	var b strings.Builder
	for _, p := range paths {
		fmt.Fprintf(&b, "path \"%s\" {\n  capabilities = [\"read\"]\n}\n", p)
	}
	return b.String()
}

func issuePolicy(paths ...string) string {
	var b strings.Builder
	for _, p := range paths {
		fmt.Fprintf(&b, "path \"%s\" {\n  capabilities = [\"create\", \"update\"]\n}\n", p)
	}
	return b.String()
}

func Default() Layout {
	return Layout{
		Mounts: []Mount{
			{Vault: Root, Path: MountKVRoot, Kind: MountKV},
			{Vault: Root, Path: MountPKIRoot, Kind: MountPKI, MaxLeaseTTL: 8760 * time.Hour},
			{Vault: Cluster, Path: MountKVCluster, Kind: MountKV},
		},
		Policies: []Policy{
			{Vault: Root, Name: PolicyKCPRuntime, HCL: readPolicy(PathKCPAdmin)},
			{Vault: Root, Name: PolicyKCPAdminTokenStore, HCL: readPolicy(PathKCPAdminTokenStore)},
			{Vault: Root, Name: PolicyKCPCA, HCL: readPolicy(PathKCPClientCA)},
			{Vault: Root, Name: PolicyContainerTLS, HCL: readPolicy(PathContainerTLS)},
			{Vault: Root, Name: PolicyKCPClientIssue, HCL: issuePolicy(MountPKIRoot + "issue/" + RoleKCPClient)},

			{Vault: Cluster, Name: PolicyIdentityBundle, HCL: readPolicy(PathClusterIdentity)},
			{Vault: Cluster, Name: PolicyInstanceToken, HCL: readPolicy(PathClusterInstanceToken)},
			{Vault: Cluster, Name: PolicyPostgres, HCL: readPolicy(PathClusterPostgres)},
			{Vault: Cluster, Name: PolicySSH, HCL: readPolicy(PathClusterSSH)},
		},
		AppRoles: []AppRole{
			{
				Vault:       Root,
				Name:        RoleGateway,
				Policies:    []string{PolicyKCPRuntime, PolicyKCPClientIssue},
				TokenTTL:    time.Hour,
				TokenMaxTTL: 4 * time.Hour,
				SecretIDTTL: 30 * time.Minute,
			},
			{
				Vault:       Root,
				Name:        RoleClusterProvider,
				Policies:    []string{PolicyKCPAdminTokenStore, PolicyKCPCA},
				TokenTTL:    time.Hour,
				TokenMaxTTL: 4 * time.Hour,
				SecretIDTTL: 30 * time.Minute,
			},
			{
				Vault:       Root,
				Name:        RoleContainerTLS,
				Policies:    []string{PolicyContainerTLS},
				TokenTTL:    time.Hour,
				TokenMaxTTL: 4 * time.Hour,
				SecretIDTTL: 30 * time.Minute,
			},
			{
				Vault:       Cluster,
				Name:        RoleClusterIdentity,
				Policies:    []string{PolicyIdentityBundle, PolicyInstanceToken},
				TokenTTL:    time.Hour,
				TokenMaxTTL: 4 * time.Hour,
				SecretIDTTL: 30 * time.Minute,
			},
			{
				Vault:       Cluster,
				Name:        RoleClusterData,
				Policies:    []string{PolicyPostgres},
				TokenTTL:    time.Hour,
				TokenMaxTTL: 4 * time.Hour,
				SecretIDTTL: 30 * time.Minute,
			},
			{
				Vault:       Cluster,
				Name:        RoleClusterNode,
				Policies:    []string{PolicySSH},
				TokenTTL:    time.Hour,
				TokenMaxTTL: 4 * time.Hour,
				SecretIDTTL: 30 * time.Minute,
			},
		},
		PKIRoles: []PKIRole{
			{
				Vault:            Root,
				Mount:            MountPKIRoot,
				Name:             RoleKCPClient,
				AllowedDomains:   []string{"socialweb.local"},
				AllowSubdomains:  true,
				AllowBareDomains: false,
				AllowIPSANs:      false,
				EnforceHostnames: true,
				RequireCN:        true,
				ClientAuth:       true,
				ServerAuth:       false,
				KeyUsage:         []string{"DigitalSignature", "KeyEncipherment"},
				KeyType:          "ecdsa",
				KeyBits:          256,
				TTL:              168 * time.Hour,
				MaxTTL:           720 * time.Hour,
			},
		},
		Secrets: []Secret{
			{
				Vault:    Root,
				Path:     PathKCPAdmin,
				Class:    ClassKCPAdminKubeconfig,
				Consumer: RoleGateway,
				Keys:     []string{"kubeconfig"},
			},
			{
				Vault:    Root,
				Path:     PathKCPAdminTokenStore,
				Class:    ClassKCPAdminTokenStore,
				Consumer: RoleClusterProvider,
				Keys:     []string{"token-hash"},
			},
			{
				Vault:    Root,
				Path:     PathKCPClientCA,
				Class:    ClassKCPClientCA,
				Consumer: RoleClusterProvider,
				Keys:     []string{"ca.crt"},
			},
			{
				Vault:    Root,
				Path:     PathContainerTLS,
				Class:    ClassContainerTLS,
				Consumer: RoleContainerTLS,
				Keys:     []string{"tls.crt", "tls.key"},
			},
			{
				Vault:    Cluster,
				Path:     PathClusterIdentity,
				Class:    ClassIdentityBundle,
				Consumer: RoleClusterIdentity,
				Keys:     identitybundle.Paths,
			},
			{
				Vault:    Cluster,
				Path:     PathClusterInstanceToken,
				Class:    ClassInstanceToken,
				Consumer: RoleClusterIdentity,
				Keys:     []string{"token"},
			},
			{
				Vault:    Cluster,
				Path:     PathClusterPostgres,
				Class:    ClassPostgresCredential,
				Consumer: RoleClusterData,
				Keys:     []string{"username", "password", "database"},
			},
			{
				Vault:    Cluster,
				Path:     PathClusterSSH,
				Class:    ClassPerClusterSSHKey,
				Consumer: RoleClusterNode,
				Keys:     []string{"id_ed25519", "id_ed25519.pub"},
			},
		},
		SecretClasses: []SecretClass{
			{Name: ClassKCPAdminKubeconfig, Vault: Root},
			{Name: ClassKCPAdminTokenStore, Vault: Root},
			{Name: ClassKCPClientCA, Vault: Root},
			{Name: ClassContainerTLS, Vault: Root},
			{Name: ClassIdentityBundle, Vault: Cluster},
			{Name: ClassInstanceToken, Vault: Cluster},
			{Name: ClassPostgresCredential, Vault: Cluster},
			{Name: ClassPerClusterSSHKey, Vault: Cluster},
		},
	}
}

func policyPaths(hcl string) []string {
	var out []string
	for line := range strings.SplitSeq(hcl, "\n") {
		line = strings.TrimSpace(line)
		if !strings.HasPrefix(line, "path \"") {
			continue
		}
		rest := strings.TrimPrefix(line, "path \"")
		if before, _, ok := strings.Cut(rest, "\""); ok {
			out = append(out, before)
		}
	}
	return out
}

func providedBy(mounts map[string]Mount, path string) bool {
	for _, m := range mounts {
		if strings.HasPrefix(path, m.Path) {
			return true
		}
	}
	return false
}

func Validate(l Layout) error {
	var errs []error

	mounts := map[VaultName]map[string]Mount{}
	for _, m := range l.Mounts {
		if mounts[m.Vault] == nil {
			mounts[m.Vault] = map[string]Mount{}
		}
		mounts[m.Vault][m.Path] = m
	}

	policies := map[VaultName]map[string]Policy{}
	for _, p := range l.Policies {
		if policies[p.Vault] == nil {
			policies[p.Vault] = map[string]Policy{}
		}
		if _, dup := policies[p.Vault][p.Name]; dup {
			errs = append(errs, fmt.Errorf("policy %s/%s is declared twice", p.Vault, p.Name))
		}
		policies[p.Vault][p.Name] = p
		for _, path := range policyPaths(p.HCL) {
			if !providedBy(mounts[p.Vault], path) {
				errs = append(errs, fmt.Errorf(
					"policy %s/%s references path %q, which no mount in the %s vault provides",
					p.Vault, p.Name, path, p.Vault))
			}
		}
	}

	roles := map[VaultName]map[string]AppRole{}
	for _, r := range l.AppRoles {
		if roles[r.Vault] == nil {
			roles[r.Vault] = map[string]AppRole{}
		}
		if _, dup := roles[r.Vault][r.Name]; dup {
			errs = append(errs, fmt.Errorf("role %s/%s is declared twice", r.Vault, r.Name))
		}
		roles[r.Vault][r.Name] = r
		if len(r.Policies) == 0 {
			errs = append(errs, fmt.Errorf("role %s/%s names no policy", r.Vault, r.Name))
		}
		for _, name := range r.Policies {
			if _, ok := policies[r.Vault][name]; !ok {
				errs = append(errs, fmt.Errorf(
					"role %s/%s references policy %q, which does not exist in the %s vault",
					r.Vault, r.Name, name, r.Vault))
			}
		}
	}

	for _, r := range l.PKIRoles {
		m, ok := mounts[r.Vault][r.Mount]
		if !ok {
			errs = append(errs, fmt.Errorf(
				"PKI role %s/%s names mount %q, which the %s vault does not have",
				r.Vault, r.Name, r.Mount, r.Vault))
			continue
		}
		if m.Kind != MountPKI {
			errs = append(errs, fmt.Errorf(
				"PKI role %s/%s names mount %q, which is %s and not pki",
				r.Vault, r.Name, r.Mount, m.Kind))
		}
		if r.MaxTTL > m.MaxLeaseTTL {
			errs = append(errs, fmt.Errorf(
				"PKI role %s/%s has max TTL %s, above its mount %q max lease TTL %s",
				r.Vault, r.Name, r.MaxTTL, r.Mount, m.MaxLeaseTTL))
		}
		if !r.ClientAuth && !r.ServerAuth {
			errs = append(errs, fmt.Errorf(
				"PKI role %s/%s grants neither client nor server auth", r.Vault, r.Name))
		}
	}

	declared := map[SecretClassName]SecretClass{}
	for _, c := range l.SecretClasses {
		if _, dup := declared[c.Name]; dup {
			errs = append(errs, fmt.Errorf("secret class %q is declared twice", c.Name))
		}
		declared[c.Name] = c
	}

	counts := map[SecretClassName]int{}
	for _, s := range l.Secrets {
		c, ok := declared[s.Class]
		if !ok {
			errs = append(errs, fmt.Errorf(
				"secret %s/%s has class %q, which is not declared", s.Vault, s.Path, s.Class))
			continue
		}
		counts[s.Class]++
		if c.Vault != s.Vault {
			errs = append(errs, fmt.Errorf(
				"secret %s/%s has class %q, whose home is the %s vault, not the %s vault",
				s.Vault, s.Path, s.Class, c.Vault, s.Vault))
		}
		if _, ok := roles[s.Vault][s.Consumer]; !ok {
			errs = append(errs, fmt.Errorf(
				"secret %s/%s names consumer %q, which is not a role in the %s vault",
				s.Vault, s.Path, s.Consumer, s.Vault))
		}
		if !providedBy(mounts[s.Vault], s.Path) {
			errs = append(errs, fmt.Errorf(
				"secret %s/%s is not under any mount in the %s vault", s.Vault, s.Path, s.Vault))
		}
		if s.Class == ClassIdentityBundle && len(s.Keys) != len(identitybundle.Paths) {
			errs = append(errs, fmt.Errorf(
				"secret %s/%s is the identity bundle and holds %d keys, want %d",
				s.Vault, s.Path, len(s.Keys), len(identitybundle.Paths)))
		}
	}

	for _, c := range l.SecretClasses {
		switch {
		case counts[c.Name] == 0:
			errs = append(errs, fmt.Errorf(
				"secret class %q is assigned to no secret, so it has no vault", c.Name))
		case counts[c.Name] > 1:
			errs = append(errs, fmt.Errorf(
				"secret class %q is assigned to %d secrets, so it has more than one vault",
				c.Name, counts[c.Name]))
		}
	}

	return errors.Join(errs...)
}