From c1d2366aac9681375d170993716981b4aa3d5126 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Mon, 14 Sep 2026 10:51:46 +0100 Subject: [PATCH 1/2] feat: add source bucket DID to authorize response --- commands/s3/request/cbor_gen.go | 47 +++++++++++++++++++++++++++++++-- commands/s3/request/json_gen.go | 44 ++++++++++++++++++++++++++++++ commands/s3/request/types.go | 12 ++++++--- 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/commands/s3/request/cbor_gen.go b/commands/s3/request/cbor_gen.go index 4a6eab0..b19eb94 100644 --- a/commands/s3/request/cbor_gen.go +++ b/commands/s3/request/cbor_gen.go @@ -120,12 +120,16 @@ func (t *AuthorizeOK) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 5 + fieldCount := 6 if t.Bucket == nil { fieldCount-- } + if t.SourceBucket == nil { + fieldCount-- + } + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { return err } @@ -212,6 +216,25 @@ func (t *AuthorizeOK) MarshalCBOR(w io.Writer) error { if err := t.Permissions.MarshalCBOR(cw); err != nil { return err } + + // t.SourceBucket (did.DID) (struct) + if t.SourceBucket != nil { + + if len("sourceBucket") > 8192 { + return xerrors.Errorf("Value in field \"sourceBucket\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sourceBucket"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sourceBucket")); err != nil { + return err + } + + if err := t.SourceBucket.MarshalCBOR(cw); err != nil { + return err + } + } return nil } @@ -240,7 +263,7 @@ func (t *AuthorizeOK) UnmarshalCBOR(r io.Reader) (err error) { n := extra - nameBuf := make([]byte, 11) + nameBuf := make([]byte, 12) for i := uint64(0); i < n; i++ { nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) if err != nil { @@ -316,6 +339,26 @@ func (t *AuthorizeOK) UnmarshalCBOR(r io.Reader) (err error) { } } + // t.SourceBucket (did.DID) (struct) + case "sourceBucket": + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.SourceBucket = new(did.DID) + if err := t.SourceBucket.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.SourceBucket pointer: %w", err) + } + } + + } default: // Field doesn't exist on this type, so ignore it diff --git a/commands/s3/request/json_gen.go b/commands/s3/request/json_gen.go index 33c3669..6eb29cc 100644 --- a/commands/s3/request/json_gen.go +++ b/commands/s3/request/json_gen.go @@ -199,6 +199,30 @@ func (t *AuthorizeOK) MarshalDagJSON(w io.Writer) error { return fmt.Errorf("marshaling field t.Permissions: %w", err) } written = true + if t.SourceBucket != nil { + if written { + if err := jw.WriteComma(); err != nil { + return err + } + } + } + + // t.SourceBucket (did.DID) (struct) + if t.SourceBucket != nil { + if len("sourceBucket") > 8192 { + return fmt.Errorf("string in field \"sourceBucket\" was too long") + } + if err := jw.WriteString(string("sourceBucket")); err != nil { + return fmt.Errorf("writing string for field \"sourceBucket\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if err := t.SourceBucket.MarshalDagJSON(jw); err != nil { + return fmt.Errorf("marshaling field t.SourceBucket: %w", err) + } + written = true + } if written { if err := jw.WriteComma(); err != nil { return err @@ -298,6 +322,26 @@ func (t *AuthorizeOK) UnmarshalDagJSON(r io.Reader) (err error) { return fmt.Errorf("unmarshaling t.Permissions: %w", err) } + // t.SourceBucket (did.DID) (struct) + case "sourceBucket": + + { + null, err := jr.PeekNull() + if err != nil { + return fmt.Errorf("peeking null for field t.SourceBucket: %w", err) + } + if null { + if err := jr.ReadNull(); err != nil { + return fmt.Errorf("reading null for field t.SourceBucket: %w", err) + } + } else { + t.SourceBucket = new(did.DID) + if err := t.SourceBucket.UnmarshalDagJSON(jr); err != nil { + return fmt.Errorf("unmarshaling t.SourceBucket pointer: %w", err) + } + } + } + // t.Tenant (did.DID) (struct) case "tenant": diff --git a/commands/s3/request/types.go b/commands/s3/request/types.go index cf806ac..7e69cfa 100644 --- a/commands/s3/request/types.go +++ b/commands/s3/request/types.go @@ -12,9 +12,9 @@ type AuthorizeArguments struct { } // AuthorizeOK is the successful result of `/s3/request/authorize`. It carries -// the resolved bucket DID, the tenant DID, the S3 permission set for the access -// key, the derived signing key(s) and the (24-hour TTL) delegations -// re-delegated to the invocation issuer. +// the resolved bucket DID (and, for a copy, the source bucket's), the tenant +// DID, the S3 permission set for the access key, the derived signing key(s) +// and the (24-hour TTL) delegations re-delegated to the invocation issuer. // // Its Permissions, Keys and Delegations fields are slice-valued maps that // cbor-gen / dag-json-gen cannot generate inline, but they are wrapped in @@ -25,6 +25,12 @@ type AuthorizeOK struct { // requests are bucket-scoped, so this field may be nil. e.g. CreateBucket, // ListAllMyBuckets, etc. Bucket *did.DID `cborgen:"bucket,omitempty" dagjsongen:"bucket,omitempty"` + // SourceBucket is the DID of the bucket a copy (CopyObject, UploadPartCopy) + // reads from, as resolved and authorized by the service; nil for any other + // request. It equals Bucket for a copy within one bucket. The gateway keys + // what it caches about the request's buckets by the DIDs the service + // names, so a copy across buckets names both. + SourceBucket *did.DID `cborgen:"sourceBucket,omitempty" dagjsongen:"sourceBucket,omitempty"` // Tenant is the DID of the tenant the access key belongs to (the tenant's // did:plc). The gateway resolves its DID document to obtain the tenant's // wrap key — the FEE tenant recipient every stored object is encrypted to. From c7713a78d7fc483506dbc6b6b7185155d0226351 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Mon, 14 Sep 2026 11:06:10 +0100 Subject: [PATCH 2/2] refactor: address PR feedback --- commands/s3/codec_test.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/commands/s3/codec_test.go b/commands/s3/codec_test.go index 9510199..55998ee 100644 --- a/commands/s3/codec_test.go +++ b/commands/s3/codec_test.go @@ -74,7 +74,10 @@ func TestAuthorizeOKRoundTrip(t *testing.T) { in := &request.AuthorizeOK{ Bucket: ptr(did.MustParse("did:key:z6MkmNBgCewjYfEDTdKLpHkbMWUogJk29CxmiVdLeW4Kz3UG")), - Tenant: did.MustParse("did:plc:ewvi7nxzyoun6zhxrhs64oiz"), + // A copy across buckets: the source is a distinct DID, so a wrong key, + // map count or pointer decode for it cannot hide behind Bucket. + SourceBucket: ptr(did.MustParse("did:key:z6MkjFRxLLGdBqQSLkZbVnuwUFiomK8eGBkPtim9ETvP7vec")), + Tenant: did.MustParse("did:plc:ewvi7nxzyoun6zhxrhs64oiz"), Permissions: s3.PermissionSet{Entries: map[did.DID][]string{ access: {"s3:GetObject", "s3:PutObject"}, }}, @@ -109,6 +112,10 @@ func TestAuthorizeOKRoundTrip(t *testing.T) { if !reflect.DeepEqual(*in, outJSON) { t.Fatalf("DAG-JSON round-trip mismatch:\n got %#v\nwant %#v", outJSON, *in) } + // The source travels under its own key, distinct from the addressed bucket. + if got := jb.String(); !strings.Contains(got, `"sourceBucket":"did:key:z6MkjFRxLLGdBqQSLkZbVnuwUFiomK8eGBkPtim9ETvP7vec"`) { + t.Fatalf("expected sourceBucket in DAG-JSON, got: %s", got) + } } func TestAuthorizeOKEmptyValuesRoundTrip(t *testing.T) { @@ -127,6 +134,7 @@ func TestAuthorizeOKEmptyValuesRoundTrip(t *testing.T) { t.Fatalf("UnmarshalDagJSON: %v\njson: %s", err, jb.String()) } require.Nil(t, out.Bucket) + require.Nil(t, out.SourceBucket) if len(out.Permissions.Entries) != 0 || len(out.Keys.Entries) != 0 || len(out.Delegations.Entries) != 0 { t.Fatalf("expected empty maps, got %#v", out) }