diff --git a/.claude/rules/transparent-proxy.md b/.claude/rules/transparent-proxy.md index d26731cc..0b2c4be6 100644 --- a/.claude/rules/transparent-proxy.md +++ b/.claude/rules/transparent-proxy.md @@ -42,7 +42,8 @@ TAG adds four proxy headers using HMAC-SHA256 over `forwarded_host\ntimestamp\nm ## Per-Bucket Authorization - `AuthzCache` keyed on `(accessKey, bucket)`. Auth for one bucket does not grant access to others. -- Granted when Tigris returns 2xx with signing keys +- `AuthzCache` authorization is granted only after a successful upstream client `GetObject` or `HeadObject`; writes and other passthrough operations do not grant cached-read access. +- Signing keys may be learned from any successful Tigris response carrying the internal header; they prove identity, not read permission. - Revoked when Tigris returns 403 - TTL: 10 minutes (configurable via `TAG_AUTHZ_CACHE_TTL`) diff --git a/README.md b/README.md index c8d6fdb7..1b66c869 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ See [docs/metrics.md](docs/metrics.md) for complete metrics reference. ## Security -TAG supports transparent proxy mode (default) with local SigV4 validation and per-bucket authorization caching, as well as signing mode with local credential stores. +TAG supports transparent proxy mode (default) with local SigV4 validation and per-bucket authorization caching, as well as signing mode with local credential stores. In transparent mode, TAG grants cached-read authorization for a bucket only after a successful upstream `GetObject` or `HeadObject`. Successful writes and other passthrough responses do not grant cached-read access. See [docs/security.md](docs/security.md) for authentication, access control, and security architecture. diff --git a/auth/authz_cache.go b/auth/authz_cache.go index 57aee55a..90fdf761 100644 --- a/auth/authz_cache.go +++ b/auth/authz_cache.go @@ -15,7 +15,7 @@ const DefaultAuthzCacheTTL = 10 * time.Minute const maxAuthzCacheSize = 1_000_000 // AuthzCache caches authorization decisions at the (access_key, bucket) level. -// Entries are granted when Tigris returns a successful response with signing keys, +// Entries are granted after a client GetObject or HeadObject succeeds upstream, // revoked when Tigris returns 403, and expire after a configurable TTL. // // Uses hashicorp/golang-lru/v2/expirable for TTL-based expiration and bounded size. diff --git a/docs/security.md b/docs/security.md index 53e533ba..0ea6a227 100644 --- a/docs/security.md +++ b/docs/security.md @@ -131,13 +131,13 @@ Request arrives at TAG ## Authorization Lifecycle -Authorization decisions are cached per `(accessKey, bucket)` pair: +Authorization decisions are cached per `(accessKey, bucket)` pair. TAG may learn signing keys from any successful response with the internal header. Those keys prove identity, not read permission. TAG grants bucket authorization only after a successful upstream `GetObject` or `HeadObject` response. Successful writes and other passthrough operations do not grant cached-read access. -| Event | Action | -| ------------------------------------ | ----------------------------------------- | -| Tigris returns 2xx with signing keys | `AuthzCache.Grant(accessKey, bucket)` | -| Tigris returns 403 | `AuthzCache.Revoke(accessKey, bucket)` | -| TTL expires (10 min default) | Entry removed, next request re-authorizes | +| Event | Action | +| -------------------------------------------------- | ----------------------------------------- | +| Upstream 2xx to client `GetObject` or `HeadObject` | `AuthzCache.Grant(accessKey, bucket)` | +| Tigris returns 403 | `AuthzCache.Revoke(accessKey, bucket)` | +| TTL expires (10 min default) | Entry removed, next request re-authorizes | Authorization is strictly per-bucket. A client may have access to some buckets but not others, and TAG enforces this at the cache level. diff --git a/proxy/forwarder_transparent.go b/proxy/forwarder_transparent.go index 9a9ab6f4..44709627 100644 --- a/proxy/forwarder_transparent.go +++ b/proxy/forwarder_transparent.go @@ -41,13 +41,51 @@ func (f *transparentForwarder) initInterceptor() { } // interceptResponse is called by base forwarder methods after receiving the -// upstream response but before headers are sent to the client. It extracts -// signing keys from successful responses and revokes authZ on 403s. +// upstream response but before headers are sent to the client. It learns +// signing keys, grants authorization only for successful object reads, and +// revokes authZ on 403s. func (f *transparentForwarder) interceptResponse(resp *http.Response, originalReq *http.Request) { f.learnSigningKeys(resp, originalReq) + f.grantReadAuthorization(resp, originalReq) f.handleAuthzRevocation(resp, originalReq) } +// isReadAuthorizationQuery accepts only selectors and authentication metadata +// known not to select a different S3 operation. The basic object route also +// receives unrecognized GET/HEAD subresources, so an unknown or malformed +// query cannot prove that the origin authorized GetObject or HeadObject. +func isReadAuthorizationQuery(rawQuery string) bool { + if rawQuery == "" { + return true + } + + query, err := url.ParseQuery(rawQuery) + if err != nil { + return false + } + + for key := range query { + switch key { + case "versionId", "partNumber", "response-cache-control", "response-content-disposition", + "response-content-encoding", "response-content-language", "response-content-type", "response-expires", + "X-Amz-Checksum-Mode", "x-amz-expected-bucket-owner", "x-amz-request-payer": + continue + } + + allowedAuthOrSDKParam := false + for _, param := range originlessIgnoredParams { + if key == param { + allowedAuthOrSDKParam = true + break + } + } + if !allowedAuthOrSDKParam { + return false + } + } + return true +} + // shallowHeaderCopy gives the forwarded request its own header map while // reusing the immutable value slices owned by the client request. Header.Set // and Header.Del replace or remove entries, so the values for headers changed @@ -286,6 +324,7 @@ func (f *transparentForwarder) DoRequestWithCreds(ctx context.Context, r *http.R // learnSigningKeys extracts and caches derived signing keys from the Tigris response. // The signing keys header is always stripped before the response reaches the client. +// Learning identity keys does not grant authorization to read cached objects. func (f *transparentForwarder) learnSigningKeys(resp *http.Response, r *http.Request) { // Always strip the internal header, even when local auth is disabled. headerVal := resp.Header.Get(signingKeysHeader) @@ -329,7 +368,6 @@ func (f *transparentForwarder) learnSigningKeys(resp *http.Response, r *http.Req } bucket, _ := ParseBucketKey(r) - f.authzCache.Grant(authInfo.AccessKey, bucket) if newKeys > 0 { log.Debug(). @@ -344,6 +382,29 @@ func (f *transparentForwarder) learnSigningKeys(resp *http.Response, r *http.Req metrics.AuthzCacheSize.Set(float64(f.authzCache.Count())) } +// grantReadAuthorization records bucket authorization only after an upstream +// success from a client GetObject or HeadObject. The method, object path, and +// query classifier reject other operations that can reach the basic object route. +func (f *transparentForwarder) grantReadAuthorization(resp *http.Response, r *http.Request) { + if f.validator == nil || f.keyUnwrapper == nil || f.authzCache == nil || + resp.StatusCode < 200 || resp.StatusCode >= 300 || r == nil || + (r.Method != http.MethodGet && r.Method != http.MethodHead) || !isReadAuthorizationQuery(r.URL.RawQuery) { + return + } + + authInfo, err := auth.ParseAuthInfo(r) + if err != nil { + return + } + bucket, key := ParseBucketKey(r) + if bucket == "" || key == "" { + return + } + + f.authzCache.Grant(authInfo.AccessKey, bucket) + metrics.AuthzCacheSize.Set(float64(f.authzCache.Count())) +} + // handleAuthzRevocation revokes authorization when Tigris returns 403. func (f *transparentForwarder) handleAuthzRevocation(resp *http.Response, r *http.Request) { if f.authzCache == nil || resp.StatusCode != http.StatusForbidden { diff --git a/proxy/forwarder_transparent_test.go b/proxy/forwarder_transparent_test.go index b6c6a69d..0ded84cc 100644 --- a/proxy/forwarder_transparent_test.go +++ b/proxy/forwarder_transparent_test.go @@ -1,6 +1,13 @@ package proxy import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" "errors" "io" "net/http" @@ -8,6 +15,7 @@ import ( "reflect" "strings" "testing" + "time" "github.com/tigrisdata/tag/auth" ) @@ -522,3 +530,165 @@ func TestTransparentForwarderSynchronousPathsKeepHeadersPrivate(t *testing.T) { }) } } + +const transparentResponseTestSecret = "response-interceptor-test-secret" + +func transparentResponseTestEnvelope(t *testing.T, accessKey string, plaintext []byte) string { + t.Helper() + hash := sha256.Sum256([]byte(transparentResponseTestSecret)) + block, err := aes.NewCipher(hash[:]) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + t.Fatal(err) + } + raw := gcm.Seal(nonce, nonce, plaintext, []byte(accessKey)) + return base64.StdEncoding.EncodeToString(raw) +} + +func transparentResponseTestRequest(t *testing.T, method, path, accessKey string) *http.Request { + t.Helper() + request, err := auth.NewRequestSigner("https://upstream.example.com", "us-east-1").SignRequest( + context.Background(), method, path, nil, "", accessKey, "client-secret", nil, + ) + if err != nil { + t.Fatal(err) + } + return request +} + +func TestResponseInterceptorStripsSigningKeysOnFailureAndDisabledAuth(t *testing.T) { + const accessKey = "INTERCEPTORKEY" + entries, err := json.Marshal([]auth.SigningKeyEntry{{ + Date: "20250211", + Region: "auto", + SigningKey: strings.Repeat("a", 64), + }}) + if err != nil { + t.Fatal(err) + } + validEnvelope := transparentResponseTestEnvelope(t, accessKey, entries) + wrongAADEnvelope := transparentResponseTestEnvelope(t, "OTHERACCESSKEY", entries) + corruptRaw, err := base64.StdEncoding.DecodeString(validEnvelope) + if err != nil { + t.Fatal(err) + } + corruptRaw[len(corruptRaw)-1] ^= 0x80 + corruptEnvelope := base64.StdEncoding.EncodeToString(corruptRaw) + malformedEnvelope := transparentResponseTestEnvelope(t, accessKey, []byte("{")) + emptyEnvelope := transparentResponseTestEnvelope(t, accessKey, []byte("[]")) + + tests := []struct { + name string + status int + envelope string + authOn bool + }{ + {name: "wrong AAD", status: http.StatusOK, envelope: wrongAADEnvelope, authOn: true}, + {name: "corrupt ciphertext", status: http.StatusOK, envelope: corruptEnvelope, authOn: true}, + {name: "malformed list", status: http.StatusOK, envelope: malformedEnvelope, authOn: true}, + {name: "empty list", status: http.StatusOK, envelope: emptyEnvelope, authOn: true}, + {name: "non-2xx response", status: http.StatusForbidden, envelope: validEnvelope, authOn: true}, + {name: "local authentication disabled", status: http.StatusOK, envelope: validEnvelope, authOn: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keys := auth.NewDerivedKeyStore(auth.DefaultDerivedKeyTTL) + authz := auth.NewAuthzCache(time.Minute) + fwd := &transparentForwarder{authzCache: authz} + if tt.authOn { + unwrapper, err := auth.NewKeyUnwrapper(transparentResponseTestSecret) + if err != nil { + t.Fatal(err) + } + fwd.derivedKeyStore = keys + fwd.validator = auth.NewRequestValidator(keys) + fwd.keyUnwrapper = unwrapper + } + response := &http.Response{ + StatusCode: tt.status, + Header: http.Header{signingKeysHeader: []string{tt.envelope}}, + } + fwd.interceptResponse(response, transparentResponseTestRequest(t, http.MethodPost, "/bucket/key?uploads", accessKey)) + + if got := response.Header.Get(signingKeysHeader); got != "" { + t.Fatalf("internal signing-keys header was not stripped: %q", got) + } + if keys.HasKey(accessKey) { + t.Fatal("failed, empty, non-2xx, or disabled response unexpectedly learned a signing key") + } + if authz.IsAuthorized(accessKey, "bucket") { + t.Fatal("passthrough response unexpectedly granted read authorization") + } + }) + } +} + +func TestSuccessfulObjectReadGrantsAuthorizationWithoutSigningKeyHeader(t *testing.T) { + const accessKey = "READWITHOUTKEYHEADER" + keys := auth.NewDerivedKeyStore(auth.DefaultDerivedKeyTTL) + authz := auth.NewAuthzCache(time.Minute) + unwrapper, err := auth.NewKeyUnwrapper(transparentResponseTestSecret) + if err != nil { + t.Fatal(err) + } + fwd := &transparentForwarder{ + derivedKeyStore: keys, + validator: auth.NewRequestValidator(keys), + keyUnwrapper: unwrapper, + authzCache: authz, + } + request := transparentResponseTestRequest(t, http.MethodGet, "/bucket/key", accessKey) + response := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)} + + fwd.interceptResponse(response, request) + + if !authz.IsAuthorized(accessKey, "bucket") { + t.Fatal("successful client GetObject did not grant bucket authorization without a signing-keys header") + } + if keys.HasKey(accessKey) { + t.Fatal("read authorization unexpectedly created a signing key") + } +} + +func TestReadAuthorizationQueryClassifierAcceptsOnlyObjectReadQueries(t *testing.T) { + const presignedReadParams = "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKID%2Fdate%2Fregion%2Fs3%2Faws4_request&X-Amz-Date=20261005T180039Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=signature&X-Amz-Security-Token=token&X-Amz-Checksum-Mode=ENABLED&x-amz-expected-bucket-owner=123456789012&x-amz-request-payer=requester" + tests := []struct { + name string + query string + want bool + }{ + {name: "plain object read", want: true}, + {name: "versioned read", query: "versionId=v1", want: true}, + {name: "part read", query: "partNumber=2", want: true}, + {name: "response overrides", query: "response-cache-control=no-cache&response-content-disposition=attachment&response-content-encoding=gzip&response-content-language=en&response-content-type=text%2Fplain&response-expires=Wed%2C21%20Oct%202015%2007%3A28%3A00%20GMT", want: true}, + {name: "presigned GetObject", query: presignedReadParams + "&x-id=GetObject", want: true}, + {name: "presigned HeadObject", query: presignedReadParams, want: true}, + {name: "GetObjectAttributes", query: "attributes", want: false}, + {name: "attribute selector with read selectors", query: "attributes&versionId=v1&x-id=GetObjectAttributes", want: false}, + {name: "object tagging", query: "tagging", want: false}, + {name: "object ACL", query: "acl", want: false}, + {name: "list parts", query: "uploadId=upload", want: false}, + {name: "object lock retention", query: "retention", want: false}, + {name: "object lock legal hold", query: "legal-hold", want: false}, + {name: "torrent", query: "torrent", want: false}, + {name: "unknown operation", query: "unknown=1", want: false}, + {name: "malformed query", query: "attributes%ZZ", want: false}, + {name: "malformed separator", query: "attributes;versionId=v1", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isReadAuthorizationQuery(tt.query); got != tt.want { + t.Fatalf("read authorization query = %v, want %v for %q", got, tt.want, tt.query) + } + }) + } +} diff --git a/tests/integration/transparent_authz_scope_test.go b/tests/integration/transparent_authz_scope_test.go new file mode 100644 index 00000000..99b0bb67 --- /dev/null +++ b/tests/integration/transparent_authz_scope_test.go @@ -0,0 +1,430 @@ +package integration + +import ( + "context" + "encoding/hex" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/tigrisdata/tag/auth" +) + +const ( + authzScopeBucket = "scope-write-bucket" + authzScopeObject = "private-object" + authzScopeBody = "private reader-only object body" + authzScopeWriter = "WRITEONLYKEY" + authzScopeWriterSecret = "writer-secret" + authzScopeReader = "READERKEY" + authzScopeReaderSecret = "reader-secret" + authzScopeHeadReader = "HEADONLYKEY" + authzScopeHeadSecret = "head-reader-secret" + authzScopeOtherReader = "OTHERREADERKEY" + authzScopeOtherReaderSecret = "other-reader-secret" +) + +// This origin enforces permissions independently of TAG's AuthzCache. +type authzScopeOrigin struct { + bucket string + otherBucket string + validator *auth.RequestValidator + + mu sync.RWMutex + envelopes map[string]string + + writerReads atomic.Int64 + writerAttributes atomic.Int64 + readerReads atomic.Int64 + readerOtherBucketReads atomic.Int64 + headReads atomic.Int64 + writerTags atomic.Int64 + otherReaderReads atomic.Int64 + readerDenied atomic.Bool +} + +func newAuthzScopeTestEnvironment(t *testing.T, bucket string) (*TestEnvironment, *authzScopeOrigin, *auth.CredentialStore) { + t.Helper() + credentials := auth.NewCredentialStore() + credentials.AddCredential(authzScopeWriter, authzScopeWriterSecret) + credentials.AddCredential(authzScopeReader, authzScopeReaderSecret) + credentials.AddCredential(authzScopeHeadReader, authzScopeHeadSecret) + credentials.AddCredential(authzScopeOtherReader, authzScopeOtherReaderSecret) + origin := &authzScopeOrigin{ + bucket: bucket, + otherBucket: bucket + "-other", + validator: auth.NewRequestValidator(credentials), + envelopes: make(map[string]string), + } + env := NewTestEnvironmentWithTransparentAuth(t, origin.serveHTTP) + return env, origin, credentials +} + +func (o *authzScopeOrigin) setSigningKeys(accessKey, envelope string) { + o.mu.Lock() + o.envelopes[accessKey] = envelope + o.mu.Unlock() +} + +func (o *authzScopeOrigin) signingKeys(accessKey string) string { + o.mu.RLock() + defer o.mu.RUnlock() + return o.envelopes[accessKey] +} + +func (o *authzScopeOrigin) serveHTTP(w http.ResponseWriter, r *http.Request) { + original := r.Clone(r.Context()) + if forwardedHost := r.Header.Get("X-Tigris-Forwarded-Host"); forwardedHost != "" { + original.Host = forwardedHost + } + accessKey, err := o.validator.ValidateRequest(original) + if err != nil { + authzScopeDeny(w) + return + } + + objectPath := "/" + o.bucket + "/" + authzScopeObject + otherObjectPath := "/" + o.otherBucket + "/" + authzScopeObject + switch { + case accessKey == authzScopeWriter && r.Method == http.MethodPost && r.URL.Path == objectPath && r.URL.Query().Has("uploads"): + envelope := o.signingKeys(authzScopeWriter) + if envelope == "" { + authzScopeDeny(w) + return + } + w.Header().Set("X-Tigris-Proxy-Signing-Keys", envelope) + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "scope-upload") + case accessKey == authzScopeWriter && r.Method == http.MethodGet && r.URL.Path == objectPath && r.URL.Query().Has("attributes"): + o.writerAttributes.Add(1) + envelope := o.signingKeys(authzScopeWriter) + if envelope == "" { + authzScopeDeny(w) + return + } + w.Header().Set("X-Tigris-Proxy-Signing-Keys", envelope) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "") + case accessKey == authzScopeWriter && r.Method == http.MethodGet && r.URL.Path == objectPath && r.URL.Query().Has("tagging"): + o.writerTags.Add(1) + envelope := o.signingKeys(authzScopeWriter) + if envelope == "" { + authzScopeDeny(w) + return + } + w.Header().Set("X-Tigris-Proxy-Signing-Keys", envelope) + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "") + case r.URL.Path == objectPath && r.URL.RawQuery == "" && r.Method == http.MethodGet && accessKey == authzScopeWriter: + o.writerReads.Add(1) + authzScopeDeny(w) + case r.URL.Path == objectPath && r.URL.RawQuery == "" && r.Method == http.MethodGet && accessKey == authzScopeReader: + o.readerReads.Add(1) + if o.readerDenied.Load() { + authzScopeDeny(w) + return + } + envelope := o.signingKeys(authzScopeReader) + if envelope == "" { + authzScopeDeny(w) + return + } + authzScopeWriteObject(w, true, envelope) + case r.URL.Path == objectPath && r.URL.RawQuery == "" && r.Method == http.MethodHead && accessKey == authzScopeHeadReader: + o.headReads.Add(1) + envelope := o.signingKeys(authzScopeHeadReader) + if envelope == "" { + authzScopeDeny(w) + return + } + authzScopeWriteObject(w, false, envelope) + case r.URL.Path == otherObjectPath && r.URL.RawQuery == "" && r.Method == http.MethodGet && accessKey == authzScopeOtherReader: + o.otherReaderReads.Add(1) + envelope := o.signingKeys(authzScopeOtherReader) + if envelope == "" { + authzScopeDeny(w) + return + } + authzScopeWriteObject(w, true, envelope) + case r.URL.Path == otherObjectPath && r.URL.RawQuery == "" && r.Method == http.MethodGet && accessKey == authzScopeReader: + o.readerOtherBucketReads.Add(1) + authzScopeDeny(w) + default: + authzScopeDeny(w) + } +} + +func authzScopeDeny(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, "AccessDenied") +} + +func authzScopeWriteObject(w http.ResponseWriter, withBody bool, envelope string) { + w.Header().Set("ETag", `"scope-etag"`) + w.Header().Set("Content-Length", "31") + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("X-Amz-Acl", "private") + w.Header().Set("X-Tigris-Proxy-Signing-Keys", envelope) + w.WriteHeader(http.StatusOK) + if withBody { + _, _ = io.WriteString(w, authzScopeBody) + } +} + +func authzScopeRequest(t *testing.T, endpoint, method, path, accessKey, secret string) *http.Request { + t.Helper() + request, err := auth.NewRequestSigner(endpoint, TestRegion).SignRequest(context.Background(), method, path, nil, "", accessKey, secret, nil) + if err != nil { + t.Fatal(err) + } + return request +} + +func authzScopeKeysForRequest(t *testing.T, credentials *auth.CredentialStore, accessKey string, request *http.Request) (string, []string) { + t.Helper() + info, err := auth.ParseAuthInfo(request) + if err != nil { + t.Fatal(err) + } + date, err := time.Parse("20060102", info.Date) + if err != nil { + t.Fatalf("parse signing date %q: %v", info.Date, err) + } + dates := []string{info.Date, date.Add(-24 * time.Hour).Format("20060102")} + entries := make([]auth.SigningKeyEntry, 0, len(dates)) + for _, signingDate := range dates { + key, err := credentials.GetSigningKey(accessKey, signingDate, info.Region) + if err != nil { + t.Fatal(err) + } + entries = append(entries, auth.SigningKeyEntry{Date: signingDate, Region: info.Region, SigningKey: hex.EncodeToString(key)}) + } + return encryptSigningKeysHeader(t, TestProxySecretKey, accessKey, entries), dates +} + +func authzScopeRead(t *testing.T, client *http.Client, request *http.Request) (int, http.Header, string) { + t.Helper() + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return response.StatusCode, response.Header, string(body) +} + +func waitForAuthzScopeCache(env *TestEnvironment, bucket, key string, timeout time.Duration) bool { + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + deadline := time.After(timeout) + for { + if env.IsCached(bucket, key) { + return true + } + select { + case <-ticker.C: + case <-deadline: + return false + } + } +} + +func TestTransparentAuth_WriteOnlyMultipartDoesNotGrantCachedRead(t *testing.T) { + env, origin, credentials := newAuthzScopeTestEnvironment(t, authzScopeBucket) + defer env.Close() + client := &http.Client{Timeout: 5 * time.Second} + path := "/" + authzScopeBucket + "/" + authzScopeObject + + // The origin denies a valid writer GetObject independently of TAG. + status, _, body := authzScopeRead(t, client, authzScopeRequest(t, env.UpstreamServer.URL, http.MethodGet, path, authzScopeWriter, authzScopeWriterSecret)) + if status != http.StatusForbidden || strings.Contains(body, authzScopeBody) { + t.Fatalf("origin must deny writer GetObject: status=%d private_body=%v", status, strings.Contains(body, authzScopeBody)) + } + + // An allowed reader populates the private object through TAG's real router. + readerRequest := authzScopeRequest(t, env.TAGServer.URL, http.MethodGet, path, authzScopeReader, authzScopeReaderSecret) + readerEnvelope, _ := authzScopeKeysForRequest(t, credentials, authzScopeReader, readerRequest) + origin.setSigningKeys(authzScopeReader, readerEnvelope) + status, headers, responseBody := authzScopeRead(t, client, readerRequest) + if status != http.StatusOK || responseBody != authzScopeBody || headers.Get("X-Tigris-Proxy-Signing-Keys") != "" { + t.Fatalf("authorized reader GetObject: status=%d body=%q leaked_header=%q", status, responseBody, headers.Get("X-Tigris-Proxy-Signing-Keys")) + } + if !waitForAuthzScopeCache(env, authzScopeBucket, authzScopeObject, 2*time.Second) { + t.Fatal("authorized reader did not populate the private object cache") + } + meta, found, err := env.Cache.GetMeta(context.Background(), authzScopeBucket, authzScopeObject) + if err != nil || !found || meta == nil || meta.IsPublicRead() { + t.Fatalf("cached object must remain private: found=%v meta=%v error=%v", found, meta, err) + } + + readerOriginGets := origin.readerReads.Load() + status, headers, responseBody = authzScopeRead(t, client, readerRequest.Clone(context.Background())) + if status != http.StatusOK || headers.Get("X-Cache") != "HIT" || responseBody != authzScopeBody || origin.readerReads.Load() != readerOriginGets { + t.Fatalf("authorized reader cache hit: status=%d cache=%q body=%q origin GET delta=%d", status, headers.Get("X-Cache"), responseBody, origin.readerReads.Load()-readerOriginGets) + } + + // A separate HeadObject identity also learns keys from an upstream HEAD and + // then serves the object's metadata from the shared integration cache. + headRequest := authzScopeRequest(t, env.TAGServer.URL, http.MethodHead, path, authzScopeHeadReader, authzScopeHeadSecret) + headEnvelope, _ := authzScopeKeysForRequest(t, credentials, authzScopeHeadReader, headRequest) + origin.setSigningKeys(authzScopeHeadReader, headEnvelope) + status, headers, responseBody = authzScopeRead(t, client, headRequest) + if status != http.StatusOK || responseBody != "" || headers.Get("X-Tigris-Proxy-Signing-Keys") != "" || headers.Get("Content-Length") != "31" || !env.AuthzCache.IsAuthorized(authzScopeHeadReader, authzScopeBucket) { + t.Fatalf("authorized HeadObject: status=%d body=%q content_length=%q leaked_header=%q grant=%v", status, responseBody, headers.Get("Content-Length"), headers.Get("X-Tigris-Proxy-Signing-Keys"), env.AuthzCache.IsAuthorized(authzScopeHeadReader, authzScopeBucket)) + } + headOriginGets := origin.headReads.Load() + status, headers, responseBody = authzScopeRead(t, client, headRequest.Clone(context.Background())) + if status != http.StatusOK || responseBody != "" || headers.Get("Content-Length") != "31" || headers.Get("X-Cache") != "HIT" || origin.headReads.Load() != headOriginGets { + t.Fatalf("authorized HeadObject cache hit: status=%d body=%q cache=%q origin HEAD delta=%d", status, responseBody, headers.Get("X-Cache"), origin.headReads.Load()-headOriginGets) + } + + // The signing-key response still completes multipart initiation and teaches + // the writer the keys for its exact later GetObject signature. + writerReadRequest := authzScopeRequest(t, env.TAGServer.URL, http.MethodGet, path, authzScopeWriter, authzScopeWriterSecret) + writeRequest := authzScopeRequest(t, env.TAGServer.URL, http.MethodPost, path+"?uploads", authzScopeWriter, authzScopeWriterSecret) + writerEnvelope, writerDates := authzScopeKeysForRequest(t, credentials, authzScopeWriter, writeRequest) + origin.setSigningKeys(authzScopeWriter, writerEnvelope) + status, headers, responseBody = authzScopeRead(t, client, writeRequest) + if status != http.StatusOK || responseBody != "scope-upload" || headers.Get("X-Tigris-Proxy-Signing-Keys") != "" { + t.Fatalf("multipart initiation: status=%d body=%q leaked_header=%q", status, responseBody, headers.Get("X-Tigris-Proxy-Signing-Keys")) + } + if env.AuthzCache.IsAuthorized(authzScopeWriter, authzScopeBucket) { + t.Fatal("successful multipart initiation granted the writer cached-read access") + } + for _, date := range writerDates { + key, err := env.DerivedKeyStore.GetSigningKey(authzScopeWriter, date, TestRegion) + if err != nil || len(key) != 32 { + t.Fatalf("multipart response did not teach the writer's 32-byte key for %s: length=%d error=%v", date, len(key), err) + } + } + + // A successful tagging subresource is still passthrough, not GetObject. + tagRequest := authzScopeRequest(t, env.TAGServer.URL, http.MethodGet, path+"?tagging", authzScopeWriter, authzScopeWriterSecret) + status, headers, _ = authzScopeRead(t, client, tagRequest) + if status != http.StatusOK || headers.Get("X-Tigris-Proxy-Signing-Keys") != "" || origin.writerTags.Load() != 1 { + t.Fatalf("object-tagging passthrough: status=%d leaked_header=%q origin calls=%d", status, headers.Get("X-Tigris-Proxy-Signing-Keys"), origin.writerTags.Load()) + } + if env.AuthzCache.IsAuthorized(authzScopeWriter, authzScopeBucket) { + t.Fatal("successful object-tagging passthrough granted the writer cached-read access") + } + + // GetObjectAttributes also uses GET, but its success does not prove that the + // client can read the object's body. The basic object route must not turn it + // into a bucket cache-read grant. + attributesRequest := authzScopeRequest(t, env.TAGServer.URL, http.MethodGet, path+"?attributes", authzScopeWriter, authzScopeWriterSecret) + attributesEnvelope, _ := authzScopeKeysForRequest(t, credentials, authzScopeWriter, attributesRequest) + origin.setSigningKeys(authzScopeWriter, attributesEnvelope) + attributesRequest.Header.Set("Cache-Control", "no-store") + status, headers, responseBody = authzScopeRead(t, client, attributesRequest) + if status != http.StatusOK || responseBody != "" || headers.Get("X-Tigris-Proxy-Signing-Keys") != "" || origin.writerAttributes.Load() != 1 { + t.Fatalf("GetObjectAttributes: status=%d body=%q leaked_header=%q origin calls=%d", status, responseBody, headers.Get("X-Tigris-Proxy-Signing-Keys"), origin.writerAttributes.Load()) + } + if env.AuthzCache.IsAuthorized(authzScopeWriter, authzScopeBucket) { + t.Fatal("successful GetObjectAttributes granted the writer cached-read access") + } + + // Check that this request is locally verifiable from the keys learned above. + if _, err := auth.NewRequestValidator(env.DerivedKeyStore).ValidateRequest(writerReadRequest); err != nil { + t.Fatalf("multipart response keys do not validate the writer's GetObject signature: %v", err) + } + writerOriginGets := origin.writerReads.Load() + status, headers, responseBody = authzScopeRead(t, client, writerReadRequest) + if status != http.StatusForbidden || strings.Contains(responseBody, authzScopeBody) || origin.writerReads.Load() != writerOriginGets+1 { + t.Fatalf("writer GetObject must reach origin and be denied: status=%d cache=%q private_body=%v origin GET delta=%d", status, headers.Get("X-Cache"), strings.Contains(responseBody, authzScopeBody), origin.writerReads.Load()-writerOriginGets) + } +} + +func TestTransparentAuth_ReadGrantRevokedAfter403(t *testing.T) { + const bucket = "scope-revoke-bucket" + env, origin, credentials := newAuthzScopeTestEnvironment(t, bucket) + defer env.Close() + client := &http.Client{Timeout: 5 * time.Second} + path := "/" + bucket + "/" + authzScopeObject + readerRequest := authzScopeRequest(t, env.TAGServer.URL, http.MethodGet, path, authzScopeReader, authzScopeReaderSecret) + readerEnvelope, _ := authzScopeKeysForRequest(t, credentials, authzScopeReader, readerRequest) + origin.setSigningKeys(authzScopeReader, readerEnvelope) + status, _, responseBody := authzScopeRead(t, client, readerRequest) + if status != http.StatusOK || responseBody != authzScopeBody || !env.AuthzCache.IsAuthorized(authzScopeReader, bucket) { + t.Fatalf("initial authorized GetObject: status=%d grant=%v", status, env.AuthzCache.IsAuthorized(authzScopeReader, bucket)) + } + + origin.readerDenied.Store(true) + request := authzScopeRequest(t, env.TAGServer.URL, http.MethodGet, path, authzScopeReader, authzScopeReaderSecret) + request.Header.Set("Cache-Control", "no-store") + status, _, responseBody = authzScopeRead(t, client, request) + if status != http.StatusForbidden || strings.Contains(responseBody, authzScopeBody) { + t.Fatalf("origin-denied GetObject: status=%d private_body=%v", status, strings.Contains(responseBody, authzScopeBody)) + } + if env.AuthzCache.IsAuthorized(authzScopeReader, bucket) { + t.Fatal("upstream 403 did not revoke the read grant") + } +} + +func TestTransparentAuth_AuthzGrantRemainsBucketScoped(t *testing.T) { + const bucketA = "scope-authz-bucket-a" + bucketB := bucketA + "-other" + env, origin, credentials := newAuthzScopeTestEnvironment(t, bucketA) + defer env.Close() + client := &http.Client{Timeout: 5 * time.Second} + pathA := "/" + bucketA + "/" + authzScopeObject + pathB := "/" + bucketB + "/" + authzScopeObject + + // The same access key is allowed to read bucket A but not bucket B. + readerARequest := authzScopeRequest(t, env.TAGServer.URL, http.MethodGet, pathA, authzScopeReader, authzScopeReaderSecret) + readerBRequest := authzScopeRequest(t, env.TAGServer.URL, http.MethodGet, pathB, authzScopeReader, authzScopeReaderSecret) + readerEnvelope, _ := authzScopeKeysForRequest(t, credentials, authzScopeReader, readerBRequest) + origin.setSigningKeys(authzScopeReader, readerEnvelope) + status, headers, body := authzScopeRead(t, client, readerARequest) + if status != http.StatusOK || body != authzScopeBody || headers.Get("X-Tigris-Proxy-Signing-Keys") != "" { + t.Fatalf("authorized bucket-A GetObject: status=%d body=%q leaked_header=%q", status, body, headers.Get("X-Tigris-Proxy-Signing-Keys")) + } + if !waitForAuthzScopeCache(env, bucketA, authzScopeObject, 2*time.Second) { + t.Fatal("reader A did not populate bucket A") + } + if !env.AuthzCache.IsAuthorized(authzScopeReader, bucketA) || env.AuthzCache.IsAuthorized(authzScopeReader, bucketB) { + t.Fatalf("bucket A grant leaked to bucket B: A=%v B=%v", env.AuthzCache.IsAuthorized(authzScopeReader, bucketA), env.AuthzCache.IsAuthorized(authzScopeReader, bucketB)) + } + + // Another identity populates a private cache entry in bucket B. Reader A's + // bucket-A grant must not make this private object readable from cache. + otherReaderRequest := authzScopeRequest(t, env.TAGServer.URL, http.MethodGet, pathB, authzScopeOtherReader, authzScopeOtherReaderSecret) + otherReaderEnvelope, _ := authzScopeKeysForRequest(t, credentials, authzScopeOtherReader, otherReaderRequest) + origin.setSigningKeys(authzScopeOtherReader, otherReaderEnvelope) + status, headers, body = authzScopeRead(t, client, otherReaderRequest) + if status != http.StatusOK || body != authzScopeBody || headers.Get("X-Tigris-Proxy-Signing-Keys") != "" { + t.Fatalf("authorized bucket-B cache fill: status=%d body=%q leaked_header=%q", status, body, headers.Get("X-Tigris-Proxy-Signing-Keys")) + } + if !waitForAuthzScopeCache(env, bucketB, authzScopeObject, 2*time.Second) || !env.AuthzCache.IsAuthorized(authzScopeOtherReader, bucketB) { + t.Fatal("other reader did not authorize and populate bucket B") + } + meta, found, err := env.Cache.GetMeta(context.Background(), bucketB, authzScopeObject) + if err != nil || !found || meta == nil || meta.IsPublicRead() { + t.Fatalf("bucket-B cache entry must remain private: found=%v meta=%v error=%v", found, meta, err) + } + otherReaderOriginGets := origin.otherReaderReads.Load() + status, headers, body = authzScopeRead(t, client, otherReaderRequest.Clone(context.Background())) + if status != http.StatusOK || headers.Get("X-Cache") != "HIT" || body != authzScopeBody || origin.otherReaderReads.Load() != otherReaderOriginGets { + t.Fatalf("authorized other-reader bucket-B cache hit: status=%d cache=%q body=%q origin GET delta=%d", status, headers.Get("X-Cache"), body, origin.otherReaderReads.Load()-otherReaderOriginGets) + } + + readerOriginGets := origin.readerOtherBucketReads.Load() + status, headers, body = authzScopeRead(t, client, readerBRequest) + if status != http.StatusForbidden || strings.Contains(body, authzScopeBody) || origin.readerOtherBucketReads.Load() != readerOriginGets+1 { + t.Fatalf("reader A must reach origin and be denied in bucket B: status=%d cache=%q private_body=%v origin GET delta=%d", status, headers.Get("X-Cache"), strings.Contains(body, authzScopeBody), origin.readerOtherBucketReads.Load()-readerOriginGets) + } + if !env.AuthzCache.IsAuthorized(authzScopeReader, bucketA) || env.AuthzCache.IsAuthorized(authzScopeReader, bucketB) { + t.Fatalf("bucket-B denial changed bucket grants: A=%v B=%v", env.AuthzCache.IsAuthorized(authzScopeReader, bucketA), env.AuthzCache.IsAuthorized(authzScopeReader, bucketB)) + } +}