[#488] middleware/policy: frostfs to S3 error transformation #488

Merged
alexvanin merged 1 commit from nzinkevich/frostfs-s3-gw:access_bug into master 2024-10-26 11:30:29 +00:00
Member

Moved layer public errors to api/layer/errors to avoid circular dependencies
Made transformToS3Error public and moved to api/errors package

Signed-off-by: Nikita Zinkevich n.zinkevich@yadro.com

Moved layer public errors to api/layer/errors to avoid circular dependencies Made transformToS3Error public and moved to `api/errors` package Signed-off-by: Nikita Zinkevich <n.zinkevich@yadro.com>
nzinkevich added 1 commit 2024-09-11 09:18:08 +00:00
[#XXX] Add transformation to S3 error in policy check
Some checks failed
/ DCO (pull_request) Failing after 1m0s
/ Builds (pull_request) Successful in 55s
/ Vulncheck (pull_request) Successful in 1m15s
/ Lint (pull_request) Successful in 2m8s
/ Tests (pull_request) Successful in 1m28s
7f425712c1
Moved layer public errors to api/layer/errors to avoid circular dependencies
Made transformToS3Error public and move to `api/errors` package

Signed-off-by: Nikita Zinkevich <n.zinkevich@yadro.com>
nzinkevich requested review from storage-services-committers 2024-09-11 09:18:49 +00:00
nzinkevich requested review from storage-services-developers 2024-09-11 09:18:51 +00:00
nzinkevich force-pushed access_bug from 7f425712c1 to 6d002d9164 2024-09-11 09:24:41 +00:00 Compare
nzinkevich changed title from [#XXX] Add transformation to S3 error in policy check to [#488] Add transformation to S3 error in policy check 2024-09-12 05:26:30 +00:00
nzinkevich force-pushed access_bug from 6d002d9164 to 41dc579744 2024-09-13 13:08:53 +00:00 Compare
nzinkevich changed title from [#488] Add transformation to S3 error in policy check to [#488] policy: add transformation to S3 error 2024-09-13 13:09:17 +00:00
nzinkevich force-pushed access_bug from 41dc579744 to dcdc22a87c 2024-09-13 13:12:17 +00:00 Compare
nzinkevich changed title from [#488] policy: add transformation to S3 error to [#488] policy: add error transformation to S3 2024-09-13 13:13:22 +00:00
nzinkevich changed title from [#488] policy: add error transformation to S3 to [#488] policy: add error transformation to S3 error 2024-09-13 13:13:43 +00:00
nzinkevich changed title from [#488] policy: add error transformation to S3 error to [#488] middleware/policy: frostfs to S3 error transformation 2024-09-13 13:14:53 +00:00
mbiryukova approved these changes 2024-09-16 15:20:53 +00:00
Dismissed
alexvanin approved these changes 2024-09-17 12:47:14 +00:00
Dismissed
alexvanin left a comment
Owner

Looks good to me, but I would wait for @dkirillov review.

Looks good to me, but I would wait for @dkirillov review.
alexvanin requested review from dkirillov 2024-09-17 12:47:21 +00:00
dkirillov reviewed 2024-09-23 11:34:29 +00:00
@ -6,2 +7,3 @@
frosterrors "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/frostfs/errors"
layerErr "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/layer/errors"
frostErr "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/frostfs/errors"
Member

Package name should have lower case name

Package name should have lower case name
dkirillov marked this conversation as resolved
@ -31,3 +30,3 @@
func (h *handler) logAndSendError(w http.ResponseWriter, logText string, reqInfo *middleware.ReqInfo, err error, additional ...zap.Field) {
err = handleDeleteMarker(w, err)
if code, wrErr := middleware.WriteErrorResponse(w, reqInfo, transformToS3Error(err)); wrErr != nil {
if code, wrErr := middleware.WriteErrorResponse(w, reqInfo, s3errors.TransformToS3Error(err)); wrErr != nil {
Member

I would prefer to use for example apierr or apierrors instead of s3errors

I would prefer to use for example `apierr` or `apierrors` instead of `s3errors`
dkirillov marked this conversation as resolved
@ -2,3 +2,3 @@
import (
"errors"
stdErrors "errors"
Member

Let's keep standard error package as errors

Let's keep standard error package as `errors`
dkirillov marked this conversation as resolved
@ -226,4 +223,0 @@
ErrAccessDenied = errors.New("access denied")
// ErrGatewayTimeout is returned from FrostFS in case of timeout, deadline exceeded etc.
ErrGatewayTimeout = errors.New("gateway timeout")
Member

Actually, I would try to keep errors near to interface that is related to it. Can we move interface to the same directory layer/error (it should be renamed then)?

Actually, I would try to keep errors near to interface that is related to it. Can we move interface to the same directory `layer/error` (it should be renamed then)?
dkirillov marked this conversation as resolved
@ -86,3 +85,3 @@
if err := policyCheck(r, cfg); err != nil {
reqLogOrDefault(ctx, cfg.Log).Error(logs.PolicyValidationFailed, zap.Error(err))
err = frostfsErrors.UnwrapErr(err)
err = apiErr.TransformToS3Error(err)
Member

It would be nice to have tests for such change

It would be nice to have tests for such change
Member

Also consider using the TransformToS3Error in Auth middleware

Also consider using the `TransformToS3Error` in `Auth` middleware
Author
Member

Auth already receives S3 errors here. Should we change the type of error returned in 'center.Authenticate()'?

box, err := center.Authenticate(r)

`Auth` already receives S3 errors here. Should we change the type of error returned in 'center.Authenticate()'? https://git.frostfs.info/TrueCloudLab/frostfs-s3-gw/src/commit/5358e39f713189b5aae1ffceea7a8c514552dc2d/api/middleware/auth.go#L54
Member

Yes but if some error returned from center.Authenticate isn't "s3 api error". We return access denied to client. But maybe sometime

Yes but if some error returned from `center.Authenticate` isn't "s3 api error". We return `access denied` to client. But maybe sometime * https://git.frostfs.info/TrueCloudLab/frostfs-s3-gw/src/commit/738ce14f50b1099c6c66d2dcf28b40395ca77988/api/auth/center.go#L191 * https://git.frostfs.info/TrueCloudLab/frostfs-s3-gw/src/commit/738ce14f50b1099c6c66d2dcf28b40395ca77988/internal/frostfs/authmate.go#L87 It would be nice to get original gateway timeout https://git.frostfs.info/TrueCloudLab/frostfs-s3-gw/src/commit/738ce14f50b1099c6c66d2dcf28b40395ca77988/internal/frostfs/frostfs.go#L474
Member

Please, add tests

diff --git a/api/router_mock_test.go b/api/router_mock_test.go
index 817b0d49..0a140450 100644
--- a/api/router_mock_test.go
+++ b/api/router_mock_test.go
@@ -40,8 +40,9 @@ type centerMock struct {
 	t            *testing.T
 	anon         bool
 	noAuthHeader bool
-	isError      bool
+	err          error
 	attrs        []object.Attribute
+	key          *keys.PrivateKey
 }
 
 func (c *centerMock) Authenticate(*http.Request) (*middleware.Box, error) {
@@ -49,8 +50,8 @@ func (c *centerMock) Authenticate(*http.Request) (*middleware.Box, error) {
 		return nil, middleware.ErrNoAuthorizationHeader
 	}
 
-	if c.isError {
-		return nil, fmt.Errorf("some error")
+	if c.err != nil {
+		return nil, c.err
 	}
 
 	var token *bearer.Token
@@ -58,8 +59,13 @@ func (c *centerMock) Authenticate(*http.Request) (*middleware.Box, error) {
 	if !c.anon {
 		bt := bearertest.Token()
 		token = &bt
-		key, err := keys.NewPrivateKey()
-		require.NoError(c.t, err)
+
+		key := c.key
+		if key == nil {
+			var err error
+			key, err = keys.NewPrivateKey()
+			require.NoError(c.t, err)
+		}
 		require.NoError(c.t, token.Sign(key.PrivateKey))
 	}
 
@@ -151,22 +157,21 @@ func (m *xmlMock) NewXMLDecoder(r io.Reader) *xml.Decoder {
 }
 
 type resourceTaggingMock struct {
-	bucketTags      map[string]string
-	objectTags      map[string]string
-	noSuchObjectKey bool
-	noSuchBucketKey bool
+	bucketTags map[string]string
+	objectTags map[string]string
+	err        error
 }
 
 func (m *resourceTaggingMock) GetBucketTagging(context.Context, *data.BucketInfo) (map[string]string, error) {
-	if m.noSuchBucketKey {
-		return nil, apierr.GetAPIError(apierr.ErrNoSuchKey)
+	if m.err != nil {
+		return nil, m.err
 	}
 	return m.bucketTags, nil
 }
 
 func (m *resourceTaggingMock) GetObjectTagging(context.Context, *data.GetObjectTaggingParams) (string, map[string]string, error) {
-	if m.noSuchObjectKey {
-		return "", nil, apierr.GetAPIError(apierr.ErrNoSuchKey)
+	if m.err != nil {
+		return "", nil, m.err
 	}
 	return "", m.objectTags, nil
 }
diff --git a/api/router_test.go b/api/router_test.go
index c4a524f3..509c9c19 100644
--- a/api/router_test.go
+++ b/api/router_test.go
@@ -15,6 +15,7 @@ import (
 
 	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/data"
 	apierr "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors"
+	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/layer/frostfs"
 	s3middleware "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/middleware"
 	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/metrics"
 	"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object"
@@ -26,6 +27,7 @@ import (
 	"git.frostfs.info/TrueCloudLab/policy-engine/schema/s3"
 	"github.com/go-chi/chi/v5"
 	"github.com/go-chi/chi/v5/middleware"
+	"github.com/nspcc-dev/neo-go/pkg/crypto/keys"
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/stretchr/testify/require"
 	"go.uber.org/zap/zaptest"
@@ -218,6 +220,36 @@ func TestPolicyChecker(t *testing.T) {
 	deleteObjectErr(chiRouter, ns2, bktName1, objName2, nil, apierr.ErrAccessDenied)
 }
 
+func TestPolicyCheckFrostfsErrors(t *testing.T) {
+	chiRouter := prepareRouter(t)
+	ns1, bktName1, objName1 := "", "bucket", "object"
+
+	createBucket(chiRouter, ns1, bktName1)
+	key, err := keys.NewPrivateKey()
+	require.NoError(t, err)
+	chiRouter.cfg.Center.(*centerMock).key = key
+	chiRouter.cfg.MiddlewareSettings.(*middlewareSettingsMock).denyByDefault = true
+
+	ruleChain := &chain.Chain{
+		ID: chain.ID("id"),
+		Rules: []chain.Rule{{
+			Status:    chain.Allow,
+			Actions:   chain.Actions{Names: []string{"*"}},
+			Resources: chain.Resources{Names: []string{fmt.Sprintf(s3.ResourceFormatS3BucketObjects, bktName1)}},
+		}},
+	}
+
+	_, _, err = chiRouter.policyChecker.MorphRuleChainStorage().AddMorphRuleChain(chain.S3, engine.UserTarget(ns1+":"+key.Address()), ruleChain)
+	require.NoError(t, err)
+
+	// check we can access 'bucket' in default namespace
+	putObject(chiRouter, ns1, bktName1, objName1, nil)
+
+	chiRouter.cfg.Center.(*centerMock).anon = true
+	chiRouter.cfg.Tagging.(*resourceTaggingMock).err = frostfs.ErrAccessDenied
+	getObjectErr(chiRouter, ns1, bktName1, objName1, apierr.ErrAccessDenied)
+}
+
 func TestPolicyCheckerError(t *testing.T) {
 	chiRouter := prepareRouter(t)
 	ns1, bktName1, objName1 := "", "bucket", "object"
@@ -524,11 +556,10 @@ func TestResourceTagsCheck(t *testing.T) {
 
 		listObjectsV1Err(router, ns, bktName, "", "", "", apierr.ErrNoSuchBucket)
 
-		router.cfg.Tagging.(*resourceTaggingMock).noSuchBucketKey = true
+		router.cfg.Tagging.(*resourceTaggingMock).err = apierr.GetAPIError(apierr.ErrNoSuchKey)
 		createBucket(router, ns, bktName)
 		getBucketErr(router, ns, bktName, apierr.ErrNoSuchKey)
 
-		router.cfg.Tagging.(*resourceTaggingMock).noSuchObjectKey = true
 		createBucket(router, ns, bktName)
 		getObjectErr(router, ns, bktName, objName, apierr.ErrNoSuchKey)
 	})
@@ -826,8 +857,11 @@ func TestAuthenticate(t *testing.T) {
 	createBucket(chiRouter, "", "bkt-2")
 
 	chiRouter = prepareRouter(t)
-	chiRouter.cfg.Center.(*centerMock).isError = true
+	chiRouter.cfg.Center.(*centerMock).err = fmt.Errorf("some error")
 	createBucketErr(chiRouter, "", "bkt-3", nil, apierr.ErrAccessDenied)
+
+	chiRouter.cfg.Center.(*centerMock).err = frostfs.ErrGatewayTimeout
+	createBucketErr(chiRouter, "", "bkt-3", nil, apierr.ErrGatewayTimeout)
 }
 
 func TestFrostFSIDValidation(t *testing.T) {

Please, add tests ```diff diff --git a/api/router_mock_test.go b/api/router_mock_test.go index 817b0d49..0a140450 100644 --- a/api/router_mock_test.go +++ b/api/router_mock_test.go @@ -40,8 +40,9 @@ type centerMock struct { t *testing.T anon bool noAuthHeader bool - isError bool + err error attrs []object.Attribute + key *keys.PrivateKey } func (c *centerMock) Authenticate(*http.Request) (*middleware.Box, error) { @@ -49,8 +50,8 @@ func (c *centerMock) Authenticate(*http.Request) (*middleware.Box, error) { return nil, middleware.ErrNoAuthorizationHeader } - if c.isError { - return nil, fmt.Errorf("some error") + if c.err != nil { + return nil, c.err } var token *bearer.Token @@ -58,8 +59,13 @@ func (c *centerMock) Authenticate(*http.Request) (*middleware.Box, error) { if !c.anon { bt := bearertest.Token() token = &bt - key, err := keys.NewPrivateKey() - require.NoError(c.t, err) + + key := c.key + if key == nil { + var err error + key, err = keys.NewPrivateKey() + require.NoError(c.t, err) + } require.NoError(c.t, token.Sign(key.PrivateKey)) } @@ -151,22 +157,21 @@ func (m *xmlMock) NewXMLDecoder(r io.Reader) *xml.Decoder { } type resourceTaggingMock struct { - bucketTags map[string]string - objectTags map[string]string - noSuchObjectKey bool - noSuchBucketKey bool + bucketTags map[string]string + objectTags map[string]string + err error } func (m *resourceTaggingMock) GetBucketTagging(context.Context, *data.BucketInfo) (map[string]string, error) { - if m.noSuchBucketKey { - return nil, apierr.GetAPIError(apierr.ErrNoSuchKey) + if m.err != nil { + return nil, m.err } return m.bucketTags, nil } func (m *resourceTaggingMock) GetObjectTagging(context.Context, *data.GetObjectTaggingParams) (string, map[string]string, error) { - if m.noSuchObjectKey { - return "", nil, apierr.GetAPIError(apierr.ErrNoSuchKey) + if m.err != nil { + return "", nil, m.err } return "", m.objectTags, nil } diff --git a/api/router_test.go b/api/router_test.go index c4a524f3..509c9c19 100644 --- a/api/router_test.go +++ b/api/router_test.go @@ -15,6 +15,7 @@ import ( "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/data" apierr "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors" + "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/layer/frostfs" s3middleware "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/middleware" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/metrics" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object" @@ -26,6 +27,7 @@ import ( "git.frostfs.info/TrueCloudLab/policy-engine/schema/s3" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" @@ -218,6 +220,36 @@ func TestPolicyChecker(t *testing.T) { deleteObjectErr(chiRouter, ns2, bktName1, objName2, nil, apierr.ErrAccessDenied) } +func TestPolicyCheckFrostfsErrors(t *testing.T) { + chiRouter := prepareRouter(t) + ns1, bktName1, objName1 := "", "bucket", "object" + + createBucket(chiRouter, ns1, bktName1) + key, err := keys.NewPrivateKey() + require.NoError(t, err) + chiRouter.cfg.Center.(*centerMock).key = key + chiRouter.cfg.MiddlewareSettings.(*middlewareSettingsMock).denyByDefault = true + + ruleChain := &chain.Chain{ + ID: chain.ID("id"), + Rules: []chain.Rule{{ + Status: chain.Allow, + Actions: chain.Actions{Names: []string{"*"}}, + Resources: chain.Resources{Names: []string{fmt.Sprintf(s3.ResourceFormatS3BucketObjects, bktName1)}}, + }}, + } + + _, _, err = chiRouter.policyChecker.MorphRuleChainStorage().AddMorphRuleChain(chain.S3, engine.UserTarget(ns1+":"+key.Address()), ruleChain) + require.NoError(t, err) + + // check we can access 'bucket' in default namespace + putObject(chiRouter, ns1, bktName1, objName1, nil) + + chiRouter.cfg.Center.(*centerMock).anon = true + chiRouter.cfg.Tagging.(*resourceTaggingMock).err = frostfs.ErrAccessDenied + getObjectErr(chiRouter, ns1, bktName1, objName1, apierr.ErrAccessDenied) +} + func TestPolicyCheckerError(t *testing.T) { chiRouter := prepareRouter(t) ns1, bktName1, objName1 := "", "bucket", "object" @@ -524,11 +556,10 @@ func TestResourceTagsCheck(t *testing.T) { listObjectsV1Err(router, ns, bktName, "", "", "", apierr.ErrNoSuchBucket) - router.cfg.Tagging.(*resourceTaggingMock).noSuchBucketKey = true + router.cfg.Tagging.(*resourceTaggingMock).err = apierr.GetAPIError(apierr.ErrNoSuchKey) createBucket(router, ns, bktName) getBucketErr(router, ns, bktName, apierr.ErrNoSuchKey) - router.cfg.Tagging.(*resourceTaggingMock).noSuchObjectKey = true createBucket(router, ns, bktName) getObjectErr(router, ns, bktName, objName, apierr.ErrNoSuchKey) }) @@ -826,8 +857,11 @@ func TestAuthenticate(t *testing.T) { createBucket(chiRouter, "", "bkt-2") chiRouter = prepareRouter(t) - chiRouter.cfg.Center.(*centerMock).isError = true + chiRouter.cfg.Center.(*centerMock).err = fmt.Errorf("some error") createBucketErr(chiRouter, "", "bkt-3", nil, apierr.ErrAccessDenied) + + chiRouter.cfg.Center.(*centerMock).err = frostfs.ErrGatewayTimeout + createBucketErr(chiRouter, "", "bkt-3", nil, apierr.ErrGatewayTimeout) } func TestFrostFSIDValidation(t *testing.T) { ```
Author
Member

Added

Added
nzinkevich force-pushed access_bug from dcdc22a87c to ff02c86dbf 2024-09-24 15:28:34 +00:00 Compare
nzinkevich dismissed mbiryukova's review 2024-09-24 15:28:34 +00:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

nzinkevich dismissed alexvanin's review 2024-09-24 15:28:34 +00:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

dkirillov reviewed 2024-09-25 06:53:12 +00:00
Member

Why don't we just move whole file (without any change)? The same for api/layer/tree_service.go

Why don't we just move whole file (without any change)? The same for `api/layer/tree_service.go`
Author
Member

Moved entire frostfs.go and tree_service.go to packages layer/frostfs and layer/tree respectively

Moved entire frostfs.go and tree_service.go to packages layer/frostfs and layer/tree respectively
dkirillov marked this conversation as resolved
@ -4,2 +4,2 @@
access_key: CAtUxDSSFtuVyVCjHTMhwx3eP3YSPo5ffwbPcnKfcdrD06WwUSn72T5EBNe3jcgjL54rmxFM6u3nUAoNBps8qJ1PD
secret_key: 560027d81c277de7378f71cbf12a32e4f7f541de724be59bcfdbfdc925425f30
access_key: CjvKZyyVWA3au4s14SzPuaxqKW7c5EBRThwt4aaWPxeu0E1CQYfJ91yxtGZ62F7rJMBf2dQXnUDsmfeRnRx6huHNt
secret_key: 2c318a7e56e2b69335176e7ae12f44ba15a34941e452f715e93a933514d20381
Member

Why do we change this?

Why do we change this?
Author
Member

Fixed

Fixed
dkirillov marked this conversation as resolved
metrics/pool.go Outdated
@ -108,4 +108,2 @@
m.requestDuration.WithLabelValues(node.Address(), methodListContainer).Set(float64(node.AverageListContainer().Milliseconds()))
m.requestDuration.WithLabelValues(node.Address(), methodDeleteContainer).Set(float64(node.AverageDeleteContainer().Milliseconds()))
m.requestDuration.WithLabelValues(node.Address(), methodGetContainerEacl).Set(float64(node.AverageGetContainerEACL().Milliseconds()))
m.requestDuration.WithLabelValues(node.Address(), methodSetContainerEacl).Set(float64(node.AverageSetContainerEACL().Milliseconds()))
Member

Why do we drop it? At least it should be done in separate commit (and methodGetContainerEacl constant should be deleted as well).

Why do we drop it? At least it should be done in separate commit (and `methodGetContainerEacl` constant should be deleted as well).
Author
Member

Undo frostfs-sdk-go version, fixed by #496

Undo frostfs-sdk-go version, fixed by #496
dkirillov marked this conversation as resolved
dkirillov reviewed 2024-09-25 06:55:17 +00:00
@ -6,3 +6,3 @@
"encoding/base64"
"encoding/xml"
"errors"
stderrors "errors"
Member

Why? Keep it "errors". (The same for other similar places)

Why? Keep it `"errors"`. (The same for other similar places)
dkirillov marked this conversation as resolved
nzinkevich force-pushed access_bug from ff02c86dbf to ff4a57d686 2024-09-27 09:40:25 +00:00 Compare
alexvanin approved these changes 2024-09-30 08:20:32 +00:00
Dismissed
nzinkevich added 1 commit 2024-09-30 08:37:07 +00:00
[#488] middleware/auth: Add frostfs-to-s3 error transformation
All checks were successful
/ DCO (pull_request) Successful in 1m6s
/ Builds (pull_request) Successful in 1m25s
/ Vulncheck (pull_request) Successful in 1m39s
/ Lint (pull_request) Successful in 2m25s
/ Tests (pull_request) Successful in 1m28s
afbf50b0c7
Signed-off-by: Nikita Zinkevich <n.zinkevich@yadro.com>
nzinkevich dismissed alexvanin's review 2024-09-30 08:37:07 +00:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

nzinkevich force-pushed access_bug from afbf50b0c7 to ec1114466a 2024-10-02 09:35:52 +00:00 Compare
nzinkevich force-pushed access_bug from ec1114466a to b2df6d3a67 2024-10-02 09:36:54 +00:00 Compare
nzinkevich force-pushed access_bug from b2df6d3a67 to c2adbd758a 2024-10-02 10:20:32 +00:00 Compare
dkirillov approved these changes 2024-10-02 11:34:20 +00:00
alexvanin merged commit c2adbd758a into master 2024-10-02 13:34:16 +00:00
alexvanin deleted branch access_bug 2024-10-02 13:34:18 +00:00
Sign in to join this conversation.
No description provided.