~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
377 lines · 13.8 KB Raw History
package openbaoctl

import (
	"cmp"
	"context"
	"errors"
	"fmt"
	"regexp"
	"sort"
	"strings"
	"time"

	"github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/baosync"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/vault/openbao"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/vaultcert"
)

const (
	CertAuthMount  = "cert"
	CertAuthRole   = "socialweb-core"
	CorePolicy     = "socialweb-core"
	KVMount        = "secret"
	CoreTokenTTL   = "15m"
	CoreTokenMax   = "1h"
	AppRoleMount   = "approle"
	DefaultJWTRole = "jwt"

	DefaultKVMaxVersions   = 3
	DefaultTidyInterval    = 6 * time.Hour
	DefaultPKITidyInterval = "6h"
	DefaultPKITidyBuffer   = "72h"
)

var CorePolicyRules = []v1alpha1.OpenBaoPolicyRule{
	{Path: "sys/auth", Capabilities: []string{"read"}},
	{Path: "sys/auth/*", Capabilities: []string{"create", "read", "update", "delete", "sudo"}},
	{Path: "sys/mounts", Capabilities: []string{"read"}},
	{Path: "sys/mounts/*", Capabilities: []string{"create", "read", "update", "delete"}},
	{Path: "sys/policies/acl/*", Capabilities: []string{"create", "read", "update", "delete", "list"}},
	{Path: "auth/+/certs/*", Capabilities: []string{"create", "read", "update", "delete", "list"}},
	{Path: "auth/+/role/*", Capabilities: []string{"create", "read", "update", "delete", "list"}},
	{Path: "auth/+/config", Capabilities: []string{"create", "read", "update"}},
	{Path: "+/config", Capabilities: []string{"create", "read", "update"}},
	{Path: "+/config/auto-tidy", Capabilities: []string{"create", "read", "update"}},
	{Path: "auth/token/tidy", Capabilities: []string{"create", "update"}},
	{Path: "sys/leases/tidy", Capabilities: []string{"create", "update"}},
	{Path: "+/roles/*", Capabilities: []string{"create", "read", "update", "delete", "list"}},
	{Path: "+/root/generate/internal", Capabilities: []string{"create", "update"}},
	{Path: "+/metadata/*", Capabilities: []string{"read", "delete", "list"}},
}

var (
	clientNamePattern  = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
	reservedMountNames = map[string]bool{KVMount: true, CertAuthMount: true, "sys": true, "auth": true, "identity": true, "cubbyhole": true}
)

func ClientPolicy(name string) string { return "client-" + name }

func clientPolicyRules(mount string) []v1alpha1.OpenBaoPolicyRule {
	return []v1alpha1.OpenBaoPolicyRule{
		{Path: mount + "/data/*", Capabilities: []string{"create", "read", "update", "patch", "delete"}},
		{Path: mount + "/metadata/*", Capabilities: []string{"read", "delete", "list"}},
	}
}

func PolicyHCL(rules []v1alpha1.OpenBaoPolicyRule) string {
	var b strings.Builder
	for _, r := range rules {
		caps := make([]string, len(r.Capabilities))
		for i, c := range r.Capabilities {
			caps[i] = `"` + c + `"`
		}
		fmt.Fprintf(&b, "path \"%s\" {\n  capabilities = [%s]\n}\n", r.Path, strings.Join(caps, ", "))
	}
	return b.String()
}

func declaredPolicies(d *v1alpha1.OpenBaoServerDeclared) map[string]bool {
	out := map[string]bool{}
	if d != nil {
		for _, p := range d.Policies {
			out[p.Name] = true
		}
	}
	return out
}

func checkClients(clients []v1alpha1.OpenBaoServerClient, declared *v1alpha1.OpenBaoServerDeclared) error {
	names, mounts := map[string]bool{}, map[string]bool{}
	policies := declaredPolicies(declared)
	for _, c := range clients {
		if len(c.Name) > 63 || !clientNamePattern.MatchString(c.Name) || c.Name == CertAuthRole {
			return fmt.Errorf("client name %q: lower case letters, digits and dashes, and never %s", c.Name, CertAuthRole)
		}
		if c.KVMount == "" && len(c.Policies) == 0 {
			return fmt.Errorf("client %s: names neither a kvMount of its own nor a declared policy, so its token could do nothing", c.Name)
		}
		if c.KVMount != "" && (len(c.KVMount) > 63 || !clientNamePattern.MatchString(c.KVMount) || reservedMountNames[c.KVMount]) {
			return fmt.Errorf("client %s: kvMount %q is not a mount of its own (never %s, %s or a system mount)", c.Name, c.KVMount, KVMount, CertAuthMount)
		}
		for _, p := range c.Policies {
			if !policies[p] {
				return fmt.Errorf("client %s: policy %q is not among spec.declared.policies", c.Name, p)
			}
		}
		if names[c.Name] || (c.KVMount != "" && mounts[c.KVMount]) {
			return fmt.Errorf("client %s: names and kvMounts are each used once", c.Name)
		}
		names[c.Name] = true
		if c.KVMount != "" {
			mounts[c.KVMount] = true
		}
	}
	return nil
}

func clientTokenPolicies(c v1alpha1.OpenBaoServerClient) []string {
	var out []string
	if c.KVMount != "" {
		out = append(out, ClientPolicy(c.Name))
	}
	return append(out, c.Policies...)
}

type layoutInput struct {
	caPEM        []byte
	clientsCAPEM []byte
	clients      []v1alpha1.OpenBaoServerClient
	declared     *v1alpha1.OpenBaoServerDeclared
	plugins      []placedPlugin
	tidyDue      bool
	accessors    map[string]string
	grants       []v1alpha1.GuestIdentityGrant
}

func or(v, dflt string) string {
	if v == "" {
		return dflt
	}
	return v
}

func put(m map[string]any, k string, v any) {
	switch x := v.(type) {
	case string:
		if x == "" {
			return
		}
	case []string:
		if len(x) == 0 {
			return
		}
	case int32:
		if x == 0 {
			return
		}
		v = int(x)
	}
	m[k] = v
}

type caSource func(ctx context.Context, pkiMount string) (string, error)

func desired(ctx context.Context, in layoutInput, ca caSource) (baosync.Desired, []string, error) {
	var d baosync.Desired
	var pending []string
	d.AuthMounts = append(d.AuthMounts, baosync.AuthMount{Path: CertAuthMount, Type: baosync.AuthCert})
	d.SecretMounts = append(d.SecretMounts, baosync.SecretMount{Path: KVMount, Type: baosync.MountKV2})
	maxVersions := map[string]int32{}
	if in.declared != nil {
		for _, m := range in.declared.SecretMounts {
			if m.MaxVersions > 0 {
				maxVersions[strings.Trim(m.Path, "/")] = m.MaxVersions
			}
		}
	}
	kvConfig := func(mount string) baosync.Object {
		n := maxVersions[mount]
		n = cmp.Or(n, DefaultKVMaxVersions)
		return baosync.Object{Path: mount + "/config", Data: map[string]any{"max_versions": int(n)}}
	}
	d.Objects = append(d.Objects, kvConfig(KVMount))
	core := CorePolicyRules
	for _, p := range in.plugins {
		bp := p.plugin
		bp.ManagedRoles = atprotoPlugin(p.spec)
		d.Plugins = append(d.Plugins, bp)
		d.AuthMounts = append(d.AuthMounts, baosync.AuthMount{Path: strings.Trim(p.spec.MountPath, "/"), Type: p.plugin.Name})
		d.Objects = append(d.Objects, atprotoObjects(p)...)
	}
	if len(in.plugins) > 0 {
		core = append(append([]v1alpha1.OpenBaoPolicyRule(nil), CorePolicyRules...), PluginCorePolicyRules...)
	}
	if in.declared != nil && in.declared.IdentityOIDC != nil {
		core = append(append([]v1alpha1.OpenBaoPolicyRule(nil), core...), IdentityCoreRules...)
	}
	d.Policies = append(d.Policies, baosync.Policy{Name: CorePolicy, HCL: PolicyHCL(core)})
	d.Objects = append(d.Objects, baosync.Object{Path: "auth/" + CertAuthMount + "/certs/" + CertAuthRole, Data: map[string]any{
		"certificate":          string(in.caPEM),
		"allowed_common_names": []string{vaultcert.RootVaultClientCommonName},
		"token_policies":       []string{CorePolicy},
		"token_ttl":            CoreTokenTTL,
		"token_max_ttl":        CoreTokenMax,
	}})
	for _, c := range in.clients {
		if c.KVMount != "" {
			d.SecretMounts = append(d.SecretMounts, baosync.SecretMount{Path: c.KVMount, Type: baosync.MountKV2})
			d.Objects = append(d.Objects, kvConfig(c.KVMount))
			d.Policies = append(d.Policies, baosync.Policy{Name: ClientPolicy(c.Name), HCL: PolicyHCL(clientPolicyRules(c.KVMount))})
		}
		d.Objects = append(d.Objects, baosync.Object{Path: "auth/" + CertAuthMount + "/certs/" + c.Name, Data: map[string]any{
			"certificate":          string(in.clientsCAPEM),
			"allowed_common_names": []string{vaultcert.OpenBaoClientCommonName(c.Name)},
			"token_policies":       clientTokenPolicies(c),
			"token_ttl":            or(c.TokenTTL, CoreTokenTTL),
			"token_max_ttl":        or(c.TokenMaxTTL, CoreTokenMax),
		}})
	}
	decl := in.declared
	if decl == nil {
		return d, nil, nil
	}
	for _, m := range decl.SecretMounts {
		if strings.Trim(m.Path, "/") == KVMount && m.Type == baosync.MountKV2 {
			continue
		}
		d.SecretMounts = append(d.SecretMounts, baosync.SecretMount{Path: strings.Trim(m.Path, "/"), Type: m.Type, MaxLeaseTTL: m.MaxLeaseTTL})
		if m.Type == baosync.MountKV2 {
			d.Objects = append(d.Objects, kvConfig(strings.Trim(m.Path, "/")))
		}
	}
	for _, m := range decl.AuthMounts {
		p := strings.Trim(m.Path, "/")
		if p != CertAuthMount || m.Type != baosync.AuthCert {
			d.AuthMounts = append(d.AuthMounts, baosync.AuthMount{Path: p, Type: m.Type})
		}
		if m.JWT != nil {
			d.Objects = append(d.Objects, baosync.Object{Path: "auth/" + p + "/config", Data: map[string]any{
				"oidc_discovery_url": m.JWT.DiscoveryURL,
				"bound_issuer":       or(m.JWT.BoundIssuer, m.JWT.DiscoveryURL),
			}})
		}
	}
	for _, p := range decl.Policies {
		if p.Name == CorePolicy {
			return d, nil, fmt.Errorf("spec.declared.policies: %s is the controller's own policy", CorePolicy)
		}
		hcl := p.HCL
		if hcl == "" {
			hcl = PolicyHCL(p.Rules)
		} else if len(p.Rules) > 0 {
			return d, nil, fmt.Errorf("spec.declared.policies: %s carries both rules and hcl", p.Name)
		}
		rendered, missing := renderAccessors(hcl, in.accessors)
		if len(missing) > 0 {
			pending = append(pending, fmt.Sprintf("policy %s waits for the accessor of the auth mount %s", p.Name, strings.Join(missing, ", ")))
			continue
		}
		d.Policies = append(d.Policies, baosync.Policy{Name: p.Name, HCL: rendered})
	}
	cas := map[string]string{}
	for _, r := range decl.CertRoles {
		mount := strings.Trim(r.Mount, "/")
		if mount == CertAuthMount && r.Name == CertAuthRole {
			return d, nil, fmt.Errorf("spec.declared.certRoles: %s is the controller's own role", CertAuthRole)
		}
		pem, ok := cas[r.CAFromPKI]
		if !ok {
			var err error
			pem, err = ca(ctx, r.CAFromPKI)
			if err != nil {
				return d, nil, err
			}
			cas[r.CAFromPKI] = pem
		}
		if pem == "" {
			pending = append(pending, fmt.Sprintf("cert role %s/%s waits for a CA in the %s mount", mount, r.Name, r.CAFromPKI))
			continue
		}
		data := map[string]any{"certificate": pem, "allowed_common_names": r.CommonNames, "token_policies": r.Policies,
			"token_no_default_policy": r.NoDefaultPolicy}
		put(data, "token_ttl", r.TokenTTL)
		put(data, "token_max_ttl", r.TokenMaxTTL)
		d.Objects = append(d.Objects, baosync.Object{Path: "auth/" + mount + "/certs/" + r.Name, Data: data})
	}
	for _, r := range decl.JWTRoles {
		data := map[string]any{"role_type": or(r.RoleType, DefaultJWTRole),
			"bound_audiences": r.BoundAudiences, "user_claim": r.UserClaim, "token_policies": r.Policies}
		if r.UserClaim == "" || r.UserClaim == "sub" {
			data["bound_subject"] = r.BoundSubject
		} else {
			data["bound_claims"] = map[string]any{r.UserClaim: r.BoundSubject}
		}
		put(data, "token_ttl", r.TokenTTL)
		d.Objects = append(d.Objects, baosync.Object{Path: "auth/" + strings.Trim(r.Mount, "/") + "/role/" + r.Name, Data: data})
	}
	for _, r := range decl.AppRoles {
		data := map[string]any{"token_policies": r.Policies}
		put(data, "token_ttl", r.TokenTTL)
		put(data, "token_max_ttl", r.TokenMaxTTL)
		put(data, "secret_id_ttl", r.SecretIDTTL)
		d.Objects = append(d.Objects, baosync.Object{Path: "auth/" + or(strings.Trim(r.Mount, "/"), AppRoleMount) + "/role/" + r.Name, Data: data})
	}
	for _, r := range decl.PKIRoles {
		data := map[string]any{"allow_subdomains": r.AllowSubdomains, "allow_bare_domains": r.AllowBareDomains,
			"allow_any_name": r.AllowAnyName, "allow_ip_sans": r.AllowIPSANs, "enforce_hostnames": r.EnforceHostnames,
			"require_cn": r.RequireCN, "client_flag": r.ClientFlag, "server_flag": r.ServerFlag, "no_store": r.NoStore}
		put(data, "allowed_domains", r.AllowedDomains)
		put(data, "key_type", r.KeyType)
		put(data, "key_bits", r.KeyBits)
		put(data, "key_usage", r.KeyUsage)
		put(data, "organization", r.Organization)
		put(data, "ou", r.OrganizationalUnit)
		put(data, "cn_validations", r.CNValidations)
		put(data, "ttl", r.TTL)
		put(data, "max_ttl", r.MaxTTL)
		d.Objects = append(d.Objects, baosync.Object{Path: strings.Trim(r.Mount, "/") + "/roles/" + r.Name, Data: data})
	}
	for _, t := range decl.PKITidy {
		d.Objects = append(d.Objects, baosync.Object{Path: strings.Trim(t.Mount, "/") + "/config/auto-tidy", Data: map[string]any{
			"enabled": true, "tidy_cert_store": true, "tidy_revoked_certs": true, "tidy_expired_issuers": false,
			"interval_duration": or(t.Interval, DefaultPKITidyInterval), "safety_buffer": or(t.SafetyBuffer, DefaultPKITidyBuffer)}})
	}
	for _, s := range decl.KV {
		keys := append([]string(nil), s.Keys...)
		sort.Strings(keys)
		var grace time.Duration
		if s.Grace != "" {
			g, err := time.ParseDuration(s.Grace)
			if err != nil {
				return d, nil, fmt.Errorf("spec.declared.kv %s/%s: grace %q: %w", s.Mount, s.Prefix, s.Grace, err)
			}
			grace = g
		}
		d.KV = append(d.KV, baosync.KVScope{Mount: strings.Trim(s.Mount, "/"), Prefix: s.Prefix, Keys: keys, Grace: grace})
	}
	return d, pending, nil
}

func pkiCA(c *openbao.Client) caSource {
	return func(ctx context.Context, mount string) (string, error) {
		if mount == "" {
			return "", errors.New("a cert role names no caFromPKI mount")
		}
		mounts, err := c.SecretMounts(ctx)
		if err != nil {
			return "", err
		}
		if m, ok := mounts[strings.Trim(mount, "/")]; !ok || m.Type != baosync.MountPKI {
			return "", nil
		}
		pem, err := c.CAPEM(ctx, mount)
		var re *openbao.ResponseError
		if err != nil && (errors.As(err, &re) && (re.Status == 404 || re.Status == 400) || strings.Contains(err.Error(), "holds no certificate")) {
			return "", nil
		}
		return pem, err
	}
}

func loginCore(ctx context.Context, c *openbao.Client) (*openbao.Client, error) {
	return c.LoginCert(ctx, CertAuthMount, CertAuthRole)
}

func loginRefused(err error) bool {
	var re *openbao.ResponseError
	if !errors.As(err, &re) {
		return false
	}
	return re.Status == 400 || re.Status == 403 || re.Status == 404
}

func permissionDenied(err error) bool {
	var re *openbao.ResponseError
	return errors.As(err, &re) && re.Status == 403
}