github.com/authzed/spicedb@v1.32.1-0.20240520085336-ebda56537386/internal/services/v0/sharestore.go (about)

     1  package v0
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"crypto/sha256"
     7  	"encoding/base64"
     8  	"encoding/json"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"strings"
    13  
    14  	"github.com/aws/aws-sdk-go/aws"
    15  	"github.com/aws/aws-sdk-go/aws/awserr"
    16  	"github.com/aws/aws-sdk-go/aws/session"
    17  	"github.com/aws/aws-sdk-go/service/s3"
    18  )
    19  
    20  const (
    21  	sharedDataVersion = "2"
    22  	hashPrefixSize    = 12
    23  )
    24  
    25  type versioned struct {
    26  	Version string `json:"version"`
    27  }
    28  
    29  // SharedDataV1 represents the data stored in a shared playground file.
    30  type SharedDataV1 struct {
    31  	Version          string   `json:"version"`
    32  	NamespaceConfigs []string `json:"namespace_configs"`
    33  	RelationTuples   string   `json:"relation_tuples"`
    34  	ValidationYaml   string   `json:"validation_yaml"`
    35  	AssertionsYaml   string   `json:"assertions_yaml"`
    36  }
    37  
    38  // SharedDataV2 represents the data stored in a shared playground file.
    39  type SharedDataV2 struct {
    40  	Version           string `json:"version" yaml:"-"`
    41  	Schema            string `json:"schema" yaml:"schema"`
    42  	RelationshipsYaml string `json:"relationships_yaml" yaml:"relationships"`
    43  	ValidationYaml    string `json:"validation_yaml" yaml:"validation"`
    44  	AssertionsYaml    string `json:"assertions_yaml" yaml:"assertions"`
    45  }
    46  
    47  // LookupStatus is an enum for the possible ShareStore lookup outcomes.
    48  type LookupStatus int
    49  
    50  const (
    51  	// LookupError indicates an error has occurred.
    52  	LookupError LookupStatus = iota
    53  
    54  	// LookupNotFound indicates that no results were found for the specified reference.
    55  	LookupNotFound
    56  
    57  	// LookupSuccess indicates success.
    58  	LookupSuccess
    59  
    60  	// LookupConverted indicates when the results have been converted from an earlier version.
    61  	LookupConverted
    62  )
    63  
    64  // ShareStore defines the interface for sharing and loading shared playground files.
    65  type ShareStore interface {
    66  	// LookupSharedByReference returns the shared data for the given reference hash, if any.
    67  	LookupSharedByReference(reference string) (SharedDataV2, LookupStatus, error)
    68  
    69  	// StoreShared stores the given shared playground data in the backing storage, and returns
    70  	// its reference hash.
    71  	StoreShared(data SharedDataV2) (string, error)
    72  }
    73  
    74  // NewInMemoryShareStore creates a new in memory share store.
    75  func NewInMemoryShareStore(salt string) ShareStore {
    76  	return &inMemoryShareStore{
    77  		shared: map[string][]byte{},
    78  		salt:   salt,
    79  	}
    80  }
    81  
    82  type inMemoryShareStore struct {
    83  	shared map[string][]byte
    84  	salt   string
    85  }
    86  
    87  func (ims *inMemoryShareStore) LookupSharedByReference(reference string) (SharedDataV2, LookupStatus, error) {
    88  	found, ok := ims.shared[reference]
    89  	if !ok {
    90  		return SharedDataV2{}, LookupNotFound, nil
    91  	}
    92  
    93  	return unmarshalShared(found)
    94  }
    95  
    96  func (ims *inMemoryShareStore) StoreShared(shared SharedDataV2) (string, error) {
    97  	data, reference, err := marshalShared(shared, ims.salt)
    98  	if err != nil {
    99  		return "", err
   100  	}
   101  
   102  	ims.shared[reference] = data
   103  	return reference, nil
   104  }
   105  
   106  type s3ShareStore struct {
   107  	bucket   string
   108  	salt     string
   109  	s3Client *s3.S3
   110  }
   111  
   112  // NewS3ShareStore creates a new S3 share store, reading and writing the shared data to the given
   113  // bucket, with the given salt for hash computation and the given config for connecting to S3 or
   114  // and S3-compatible API.
   115  func NewS3ShareStore(bucket string, salt string, config *aws.Config) (ShareStore, error) {
   116  	sess, err := session.NewSession(config)
   117  	if err != nil {
   118  		return nil, err
   119  	}
   120  
   121  	// Create S3 service client
   122  	s3Client := s3.New(sess)
   123  	return &s3ShareStore{
   124  		salt:     salt,
   125  		s3Client: s3Client,
   126  		bucket:   bucket,
   127  	}, nil
   128  }
   129  
   130  func (s3s *s3ShareStore) createBucketForTesting() error {
   131  	cparams := &s3.CreateBucketInput{
   132  		Bucket: aws.String(s3s.bucket),
   133  	}
   134  
   135  	_, err := s3s.s3Client.CreateBucket(cparams)
   136  	return err
   137  }
   138  
   139  // NOTE: Copied from base64.go for URL-encoding, since it isn't exported.
   140  const encodeURL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
   141  
   142  func (s3s *s3ShareStore) key(reference string) (string, error) {
   143  	// Ensure it is a local safe identifier.
   144  	for _, r := range reference {
   145  		if !strings.ContainsRune(encodeURL, r) {
   146  			return "", fmt.Errorf("invalid reference")
   147  		}
   148  	}
   149  
   150  	return "shared/" + reference, nil
   151  }
   152  
   153  func (s3s *s3ShareStore) LookupSharedByReference(reference string) (SharedDataV2, LookupStatus, error) {
   154  	key, err := s3s.key(reference)
   155  	if err != nil {
   156  		return SharedDataV2{}, LookupError, err
   157  	}
   158  
   159  	ctx := context.Background()
   160  
   161  	result, err := s3s.s3Client.GetObjectWithContext(ctx, &s3.GetObjectInput{
   162  		Bucket: aws.String(s3s.bucket),
   163  		Key:    aws.String(key),
   164  	})
   165  	if err != nil {
   166  		var aerr awserr.Error
   167  		if errors.As(err, &aerr) && aerr.Code() == s3.ErrCodeNoSuchKey {
   168  			return SharedDataV2{}, LookupNotFound, nil
   169  		}
   170  		return SharedDataV2{}, LookupError, aerr
   171  	}
   172  	defer result.Body.Close()
   173  
   174  	contentBytes, err := io.ReadAll(result.Body)
   175  	if err != nil {
   176  		return SharedDataV2{}, LookupError, err
   177  	}
   178  
   179  	return unmarshalShared(contentBytes)
   180  }
   181  
   182  func (s3s *s3ShareStore) StoreShared(shared SharedDataV2) (string, error) {
   183  	data, reference, err := marshalShared(shared, s3s.salt)
   184  	if err != nil {
   185  		return "", err
   186  	}
   187  
   188  	key, err := s3s.key(reference)
   189  	if err != nil {
   190  		return "", err
   191  	}
   192  
   193  	ctx := context.Background()
   194  
   195  	_, err = s3s.s3Client.PutObjectWithContext(ctx, &s3.PutObjectInput{
   196  		Bucket:      aws.String(s3s.bucket),
   197  		Key:         aws.String(key),
   198  		Body:        bytes.NewReader(data),
   199  		ContentType: aws.String("application/json"),
   200  	})
   201  
   202  	return reference, err
   203  }
   204  
   205  func computeShareHash(salt string, data []byte) string {
   206  	h := sha256.New()
   207  	_, _ = io.WriteString(h, salt+":")
   208  	h.Write(data)
   209  
   210  	sum := h.Sum(nil)
   211  	b := make([]byte, base64.URLEncoding.EncodedLen(len(sum)))
   212  	base64.URLEncoding.Encode(b, sum)
   213  
   214  	// NOTE: According to https://github.com/golang/playground/blob/48a1655aa6e55ac2658d07abcb3b39d61784f035/share.go#L39,
   215  	// some systems don't like URLs which end in underscores, so increase the hash length until
   216  	// we don't end in one.
   217  	hashLen := hashPrefixSize
   218  	for hashLen <= len(b) && b[hashLen-1] == '_' {
   219  		hashLen++
   220  	}
   221  	return string(b)[:hashLen]
   222  }
   223  
   224  func marshalShared(shared SharedDataV2, salt string) ([]byte, string, error) {
   225  	marshalled, err := json.Marshal(shared)
   226  	if err != nil {
   227  		return []byte{}, "", err
   228  	}
   229  
   230  	return marshalled, computeShareHash(salt, marshalled), nil
   231  }
   232  
   233  func unmarshalShared(data []byte) (SharedDataV2, LookupStatus, error) {
   234  	var v versioned
   235  	err := json.Unmarshal(data, &v)
   236  	if err != nil {
   237  		return SharedDataV2{}, LookupError, err
   238  	}
   239  
   240  	switch v.Version {
   241  	case "2":
   242  		var v2 SharedDataV2
   243  		err = json.Unmarshal(data, &v2)
   244  		if err != nil {
   245  			return SharedDataV2{}, LookupError, err
   246  		}
   247  		return v2, LookupSuccess, nil
   248  
   249  	case "1":
   250  		var v1 SharedDataV1
   251  		err = json.Unmarshal(data, &v1)
   252  		if err != nil {
   253  			return SharedDataV2{}, LookupError, err
   254  		}
   255  
   256  		// Convert to a V2 data structure.
   257  		upgraded, err := upgradeSchema(v1.NamespaceConfigs)
   258  		if err != nil {
   259  			return SharedDataV2{}, LookupError, err
   260  		}
   261  
   262  		return SharedDataV2{
   263  			Schema:            upgraded,
   264  			RelationshipsYaml: v1.RelationTuples,
   265  			ValidationYaml:    v1.ValidationYaml,
   266  			AssertionsYaml:    v1.AssertionsYaml,
   267  		}, LookupConverted, nil
   268  
   269  	default:
   270  		return SharedDataV2{}, LookupError, fmt.Errorf("unsupported share version %s", v.Version)
   271  	}
   272  }