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 }