~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
381 lines · 10.1 KB Raw History
package gateway

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"slices"
	"strings"
	"time"

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

const MinClientCertTTL = 60 * time.Second

const (
	DefaultClientCertTTL = 8 * time.Hour
	MaxClientCertTTL     = 24 * time.Hour
)

var ErrIssuerUnavailable = errors.New("gateway: the certificate issuer cannot issue now")

type Caller string

type XRPCError struct {
	Name    string
	Message string
	Status  int
}

func (e *XRPCError) Error() string { return e.Name + ": " + e.Message }

func (e *XRPCError) HTTPStatus() int { return e.Status }

var (
	ErrInvalidRequest  = &XRPCError{"InvalidRequest", "the request is malformed", 400}
	ErrUnauthorized    = &XRPCError{"Unauthorized", "the caller is not authenticated", 401}
	ErrForbidden       = &XRPCError{"Forbidden", "the caller may not act for that identity", 403}
	ErrIdentityRevoked = &XRPCError{
		"IdentityRevoked", "this identity has been revoked", 403,
	}
	ErrClusterNotFound = &XRPCError{"ClusterNotFound", "no such cluster", 404}

	ErrClusterInitializing = &XRPCError{
		"ClusterInitializing", "the control plane is coming up", 429,
	}

	ErrClusterDraining = &XRPCError{"ClusterDraining", "the cluster is draining", 409}

	ErrClusterUnavailable = &XRPCError{
		"ClusterUnavailable", "the identity store could not be read", 503,
	}

	ErrNoDenyWriter = &XRPCError{
		"ClusterUnavailable",
		"this gateway was built without a revocation writer, so no deny can be written",
		503,
	}
)

func errForDeny(err error) *XRPCError {
	switch {
	case err == nil:
		return nil
	case errors.Is(err, revocation.ErrRevoked):
		return ErrIdentityRevoked
	case errors.Is(err, revocation.ErrDenyRecordMissing):
		return &XRPCError{
			"IdentityRevoked",
			"the account's identity record is missing, which is not a state a user can produce",
			403,
		}
	case errors.Is(err, revocation.ErrNoDID):
		return ErrUnauthorized
	case errors.Is(err, revocation.ErrDenyUnreadable):
		return ErrClusterUnavailable
	default:
		return unprepared(err)
	}
}

const maxCause = 300

func unprepared(cause error) *XRPCError {
	return &XRPCError{
		"ClusterUnavailable",
		"the cluster could not be prepared: " + oneLine(cause.Error()),
		503,
	}
}

func oneLine(msg string) string {
	line := strings.Join(strings.Fields(msg), " ")
	if len(line) > maxCause {
		line = line[:maxCause] + "..."
	}
	return line
}

type ClusterView struct {
	Name     string
	Phase    string
	URL      string
	Instance string
	Running  bool

	TeardownBlocked bool

	NodeCount int
}

type Store interface {
	Ensure(ctx context.Context, caller Caller, name string) error

	Kubeconfig(ctx context.Context, caller Caller, name string, ttl time.Duration, noExpiry bool) (string, error)

	Get(ctx context.Context, caller Caller, name string) (ClusterView, bool, error)
	List(ctx context.Context, caller Caller) ([]ClusterView, error)
	SetRunning(ctx context.Context, caller Caller, name string, running bool) (ClusterView, error)
	Delete(ctx context.Context, caller Caller, name string) (ClusterView, error)
}

type Revoker interface {
	WriteDeny(ctx context.Context, did string) error

	FanOut(ctx context.Context, did string) ([]revocation.ClusterView, error)

	revocation.Eraser
}

type CertRevoker interface {
	Revoke(ctx context.Context, did, cluster string) (int, error)
}

type Server struct {
	Deny  revocation.Deny
	Store Store

	Revoker Revoker

	Certs CertRevoker

	Feed Feed

	Tenant TenantAPI
}

type NameRequest struct {
	Name string
}

type KubeconfigRequest struct {
	Name string

	TTLSeconds *int64

	NoExpiry bool
}

type GetKubeconfigResponse struct {
	Kubeconfig string
	Instance   string
}

func (s *Server) GetKubeconfig(ctx context.Context, caller Caller, req KubeconfigRequest) (*GetKubeconfigResponse, error) {
	if req.NoExpiry && req.TTLSeconds != nil {
		return nil, ErrInvalidRequest
	}
	ttl, err := clientCertTTL(req.TTLSeconds)
	if err != nil {
		return nil, err
	}
	if req.NoExpiry {
		ttl = 0
	}
	if err := s.create(ctx, "getKubeconfig", caller, req.Name); err != nil {
		return nil, err
	}

	view, ok, err := s.Store.Get(ctx, caller, req.Name)
	if err != nil {
		return nil, fmt.Errorf("getKubeconfig: %w", err)
	}
	if !ok {
		return nil, ErrClusterNotFound
	}
	if !view.Running {
		return nil, ErrClusterNotRunning
	}
	kubeconfig, err := s.Store.Kubeconfig(ctx, caller, req.Name, ttl, req.NoExpiry)
	if err != nil {
		if errors.Is(err, ErrIssuerUnavailable) {
			slog.Warn("getKubeconfig: the certificate issuer is not ready", "caller", string(caller), "name", req.Name, "err", err)
			return nil, ErrClusterInitializing
		}
		return nil, fmt.Errorf("getKubeconfig: %w", err)
	}

	return &GetKubeconfigResponse{Kubeconfig: kubeconfig, Instance: view.Instance}, nil
}

func (s *Server) CreateCluster(ctx context.Context, caller Caller, req NameRequest) (ClusterView, error) {
	if err := s.create(ctx, "createCluster", caller, req.Name); err != nil {
		return ClusterView{}, err
	}
	view, err := s.Store.SetRunning(context.WithoutCancel(ctx), caller, req.Name, true)
	if err != nil {
		return ClusterView{}, err
	}
	if view.Phase == "Draining" {
		return ClusterView{}, ErrClusterDraining
	}
	return view, nil
}

func clientCertTTL(seconds *int64) (time.Duration, error) {
	if seconds == nil {
		return DefaultClientCertTTL, nil
	}
	if *seconds < int64(MinClientCertTTL/time.Second) || *seconds > int64(MaxClientCertTTL/time.Second) {
		return 0, ErrInvalidRequest
	}
	return time.Duration(*seconds) * time.Second, nil
}

func (s *Server) create(ctx context.Context, verb string, caller Caller, name string) error {
	if caller == "" {
		return ErrUnauthorized
	}
	if name == "" {
		return ErrInvalidRequest
	}

	permit, err := revocation.CreateOnRead(ctx, s.Deny, string(caller), func(ctx context.Context) error {
		return s.Store.Ensure(ctx, caller, name)
	})
	if err != nil {
		slog.Error("gateway: the create-on-read path failed",
			"verb", verb, "caller", string(caller), "name", name, "err", err)
		if x := errForDeny(err); x != nil {
			return x
		}
		return fmt.Errorf("%s: %w", verb, err)
	}

	if permit.Admitted() != string(caller) {
		return fmt.Errorf("%s: permit is for %q, caller is %q", verb, permit.Admitted(), caller)
	}
	return nil
}

func (s *Server) ControlPlaneUp(ctx context.Context, caller Caller, req NameRequest) (ClusterView, error) {
	if caller == "" {
		return ClusterView{}, ErrUnauthorized
	}
	if _, err := revocation.Admit(ctx, s.Deny, string(caller)); err != nil {
		slog.Warn("controlPlaneUp: the deny refused the caller", "caller", string(caller), "name", req.Name, "err", err)
		return ClusterView{}, errForDeny(err)
	}
	view, err := s.lookup(ctx, caller, req)
	if err != nil {
		return ClusterView{}, err
	}
	switch view.Phase {
	case "Draining":
		return ClusterView{}, ErrClusterDraining
	case "Initializing":
		return ClusterView{}, ErrClusterInitializing
	case "Unavailable":
		if view.Running {
			if _, err := s.Store.SetRunning(ctx, caller, req.Name, false); err != nil {
				return ClusterView{}, err
			}
		}
	}
	return s.Store.SetRunning(ctx, caller, req.Name, true)
}

func (s *Server) ControlPlaneDown(ctx context.Context, caller Caller, req NameRequest) (ClusterView, error) {
	if _, err := s.lookup(ctx, caller, req); err != nil {
		return ClusterView{}, err
	}
	return s.Store.SetRunning(ctx, caller, req.Name, false)
}

func (s *Server) DeleteCluster(ctx context.Context, caller Caller, req NameRequest) (ClusterView, error) {
	view, err := s.lookup(ctx, caller, req)
	if err != nil {
		return ClusterView{}, err
	}
	if view.Phase != "Draining" {
		s.revokeCerts(ctx, "deleteCluster", caller, req.Name)
	}
	return s.Store.Delete(ctx, caller, req.Name)
}

func (s *Server) revokeCerts(ctx context.Context, verb string, caller Caller, cluster string) {
	if s.Certs == nil {
		return
	}
	n, err := s.Certs.Revoke(ctx, string(caller), cluster)
	if err != nil {
		slog.Warn(verb+": the PKI revoke failed; the deny or the delete already ends access", "caller", string(caller), "name", cluster, "err", err)
		return
	}
	slog.Info(verb+": certificates revoked", "caller", string(caller), "name", cluster, "count", n)
}

func (s *Server) ListClusters(ctx context.Context, caller Caller) ([]ClusterView, error) {
	if caller == "" {
		return nil, ErrUnauthorized
	}
	return s.Store.List(ctx, caller)
}

type RevokeRequest struct {
	DID string

	Name string
}

type RevokeResponse struct {
	Revoked []revocation.ReplyEntry
}

func (s *Server) RevokeIdentity(ctx context.Context, caller Caller, req RevokeRequest) (*RevokeResponse, error) {
	if caller == "" {
		return nil, ErrUnauthorized
	}
	if req.DID != "" && req.DID != string(caller) {
		return nil, ErrForbidden
	}
	if s.Revoker == nil {
		return nil, ErrNoDenyWriter
	}

	if err := s.Revoker.WriteDeny(ctx, string(caller)); err != nil {
		return nil, fmt.Errorf("revokeIdentity: write the deny: %w", err)
	}

	observed, err := s.Revoker.FanOut(ctx, string(caller))
	if err != nil {
		return nil, fmt.Errorf("revokeIdentity: %w", err)
	}
	if req.Name != "" && !slices.ContainsFunc(observed, func(c revocation.ClusterView) bool { return c.Name == req.Name }) {
		return nil, ErrClusterNotFound
	}
	effects, err := revocation.Erase(ctx, s.Revoker, string(caller), revocation.Plan(observed))
	if err != nil {
		slog.Warn("revokeIdentity: a ServiceAccount delete failed; that cluster stays RevocationPending", "caller", string(caller), "err", err)
	}
	slog.Info("revokeIdentity: the deny is written", "caller", string(caller), "plan", string(revocation.AccountState(effects)), "clusters", revocation.Detail(effects))
	if req.Name != "" {
		effects = slices.DeleteFunc(effects, func(e revocation.Effect) bool { return e.Cluster != req.Name })
	}
	for _, e := range effects {
		if e.Cluster != "" {
			s.revokeCerts(ctx, "revokeIdentity", caller, e.Cluster)
		}
	}

	return &RevokeResponse{Revoked: revocation.Reply(effects)}, nil
}

func (s *Server) lookup(ctx context.Context, caller Caller, req NameRequest) (ClusterView, error) {
	if caller == "" {
		return ClusterView{}, ErrUnauthorized
	}
	if req.Name == "" {
		return ClusterView{}, ErrInvalidRequest
	}
	view, ok, err := s.Store.Get(ctx, caller, req.Name)
	if err != nil {
		return ClusterView{}, err
	}
	if !ok {
		return ClusterView{}, ErrClusterNotFound
	}
	return view, nil
}