From e056eb803af507011c3596691eaa21d520623fd8 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Thu, 10 Sep 2026 18:02:41 +0200 Subject: [PATCH] feat: carry the principal on S3 authorize and bucket info results Hilt binds an access key to a principal, and Ingot needs to know which principal a key belongs to so it can drop the caches it holds for that principal when a policy changes. Add an optional principal field to the results of /s3/request/authorize and /s3/bucket/info. A key that is not bound to a principal, such as a tenant-wide service credential, omits the field, so an absent field and an empty identifier stay distinguishable on the wire. Co-Authored-By: Claude Fable 5.1 --- commands/s3/bucket/cbor_gen.go | 60 ++++++++++++++++- commands/s3/bucket/json_gen.go | 47 +++++++++++++ commands/s3/bucket/types.go | 4 ++ commands/s3/codec_test.go | 116 ++++++++++++++++++++++++++++++++ commands/s3/request/cbor_gen.go | 59 +++++++++++++++- commands/s3/request/json_gen.go | 48 +++++++++++++ commands/s3/request/types.go | 4 ++ 7 files changed, 336 insertions(+), 2 deletions(-) diff --git a/commands/s3/bucket/cbor_gen.go b/commands/s3/bucket/cbor_gen.go index cb7caff..01c70f8 100644 --- a/commands/s3/bucket/cbor_gen.go +++ b/commands/s3/bucket/cbor_gen.go @@ -429,8 +429,13 @@ func (t *InfoOK) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) + fieldCount := 4 - if _, err := cw.Write([]byte{163}); err != nil { + if t.Principal == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { return err } @@ -450,6 +455,38 @@ func (t *InfoOK) MarshalCBOR(w io.Writer) error { return err } + // t.Principal (string) (string) + if t.Principal != nil { + + if len("principal") > 8192 { + return xerrors.Errorf("Value in field \"principal\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("principal"))); err != nil { + return err + } + if _, err := cw.WriteString(string("principal")); err != nil { + return err + } + + if t.Principal == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.Principal) > 8192 { + return xerrors.Errorf("Value in field t.Principal was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Principal))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.Principal)); err != nil { + return err + } + } + } + // t.Delegations (s3.ProofSet) (struct) if len("delegations") > 8192 { return xerrors.Errorf("Value in field \"delegations\" was too long") @@ -535,6 +572,27 @@ func (t *InfoOK) UnmarshalCBOR(r io.Reader) (err error) { } } + // t.Principal (string) (string) + case "principal": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + t.Principal = (*string)(&sval) + } + } // t.Delegations (s3.ProofSet) (struct) case "delegations": diff --git a/commands/s3/bucket/json_gen.go b/commands/s3/bucket/json_gen.go index 16d1db1..624caa9 100644 --- a/commands/s3/bucket/json_gen.go +++ b/commands/s3/bucket/json_gen.go @@ -484,6 +484,39 @@ func (t *InfoOK) MarshalDagJSON(w io.Writer) error { if err := t.Permissions.MarshalDagJSON(jw); err != nil { return fmt.Errorf("marshaling field t.Permissions: %w", err) } + written = true + if t.Principal != nil { + if written { + if err := jw.WriteComma(); err != nil { + return err + } + } + } + + // t.Principal (string) (string) + if t.Principal != nil { + if len("principal") > 8192 { + return fmt.Errorf("string in field \"principal\" was too long") + } + if err := jw.WriteString(string("principal")); err != nil { + return fmt.Errorf("writing string for field \"principal\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if t.Principal == nil { + if err := jw.WriteNull(); err != nil { + return fmt.Errorf("writing null for field t.Principal: %w", err) + } + } else { + if len(*t.Principal) > 8192 { + return fmt.Errorf("string in field t.Principal was too long") + } + if err := jw.WriteString(string(*t.Principal)); err != nil { + return fmt.Errorf("writing string for field t.Principal: %w", err) + } + } + } if err := jw.WriteObjectClose(); err != nil { return err } @@ -544,6 +577,20 @@ func (t *InfoOK) UnmarshalDagJSON(r io.Reader) (err error) { return fmt.Errorf("unmarshaling t.Permissions: %w", err) } + // t.Principal (string) (string) + case "principal": + { + sval, err := jr.ReadStringOrNull(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("reading string or null for field t.Principal: string too long") + } + return fmt.Errorf("reading string or null for field t.Principal: %w", err) + } + if sval != nil { + t.Principal = (*string)(sval) + } + } default: // Field doesn't exist on this type, so ignore it if err := jr.DiscardType(); err != nil { diff --git a/commands/s3/bucket/types.go b/commands/s3/bucket/types.go index 4faf6b6..e7d6b86 100644 --- a/commands/s3/bucket/types.go +++ b/commands/s3/bucket/types.go @@ -39,6 +39,10 @@ type InfoArguments struct { type InfoOK struct { // ID is the DID of the bucket. ID did.DID `cborgen:"id" dagjsongen:"id"` + // Principal is the identifier of the principal the access key is bound to, + // unique within the tenant. It is nil for a service key, which carries its + // own permissions and buckets and is bound to no principal. + Principal *string `cborgen:"principal,omitempty" dagjsongen:"principal,omitempty"` // Permissions maps the access key DID to its assigned S3 permissions. Permissions s3.PermissionSet `cborgen:"permissions" dagjsongen:"permissions"` // Delegations maps the CID of a delegation whose audience is the access key diff --git a/commands/s3/codec_test.go b/commands/s3/codec_test.go index 9510199..71c42ca 100644 --- a/commands/s3/codec_test.go +++ b/commands/s3/codec_test.go @@ -171,3 +171,119 @@ func TestInfoOKRoundTrip(t *testing.T) { t.Fatalf("DAG-JSON round-trip mismatch:\n got %#v\nwant %#v", outJSON, *in) } } + +// A nil Principal must not appear on the wire at all, and a set one must +// survive both codecs. Ingot reads the field to bind an access key to the +// principal it belongs to, so an absent field and an empty string have to stay +// distinguishable. +func TestAuthorizeOKPrincipal(t *testing.T) { + base := func() *request.AuthorizeOK { + return &request.AuthorizeOK{ + Tenant: did.MustParse("did:plc:ewvi7nxzyoun6zhxrhs64oiz"), + Permissions: s3.PermissionSet{}, + Keys: s3.KeySet{}, + Delegations: s3.ProofSet{}, + } + } + + t.Run("nil omits the key", func(t *testing.T) { + in := base() + + var cb bytes.Buffer + require.NoError(t, in.MarshalCBOR(&cb)) + require.False(t, bytes.Contains(cb.Bytes(), []byte("principal")), "CBOR carries the principal key: %x", cb.Bytes()) + var outCBOR request.AuthorizeOK + require.NoError(t, outCBOR.UnmarshalCBOR(bytes.NewReader(cb.Bytes()))) + require.Nil(t, outCBOR.Principal) + + var jb bytes.Buffer + require.NoError(t, in.MarshalDagJSON(&jb)) + require.NotContains(t, jb.String(), "principal") + var outJSON request.AuthorizeOK + require.NoError(t, outJSON.UnmarshalDagJSON(bytes.NewReader(jb.Bytes())), "json: %s", jb.String()) + require.Nil(t, outJSON.Principal) + }) + + t.Run("set value survives", func(t *testing.T) { + in := base() + in.Principal = ptr("8f2c9e14") + + var cb bytes.Buffer + require.NoError(t, in.MarshalCBOR(&cb)) + var outCBOR request.AuthorizeOK + require.NoError(t, outCBOR.UnmarshalCBOR(bytes.NewReader(cb.Bytes()))) + require.Equal(t, in.Principal, outCBOR.Principal) + + var jb bytes.Buffer + require.NoError(t, in.MarshalDagJSON(&jb)) + require.Contains(t, jb.String(), `"principal":"8f2c9e14"`) + var outJSON request.AuthorizeOK + require.NoError(t, outJSON.UnmarshalDagJSON(bytes.NewReader(jb.Bytes())), "json: %s", jb.String()) + require.Equal(t, in.Principal, outJSON.Principal) + }) + + t.Run("empty string is not nil", func(t *testing.T) { + in := base() + in.Principal = ptr("") + + var cb bytes.Buffer + require.NoError(t, in.MarshalCBOR(&cb)) + var outCBOR request.AuthorizeOK + require.NoError(t, outCBOR.UnmarshalCBOR(bytes.NewReader(cb.Bytes()))) + require.NotNil(t, outCBOR.Principal) + require.Equal(t, "", *outCBOR.Principal) + + var jb bytes.Buffer + require.NoError(t, in.MarshalDagJSON(&jb)) + var outJSON request.AuthorizeOK + require.NoError(t, outJSON.UnmarshalDagJSON(bytes.NewReader(jb.Bytes())), "json: %s", jb.String()) + require.NotNil(t, outJSON.Principal) + require.Equal(t, "", *outJSON.Principal) + }) +} + +func TestInfoOKPrincipal(t *testing.T) { + base := func() *bucket.InfoOK { + return &bucket.InfoOK{ + ID: did.MustParse("did:key:z6MkmNBgCewjYfEDTdKLpHkbMWUogJk29CxmiVdLeW4Kz3UG"), + Permissions: s3.PermissionSet{}, + Delegations: s3.ProofSet{}, + } + } + + t.Run("nil omits the key", func(t *testing.T) { + in := base() + + var cb bytes.Buffer + require.NoError(t, in.MarshalCBOR(&cb)) + require.False(t, bytes.Contains(cb.Bytes(), []byte("principal")), "CBOR carries the principal key: %x", cb.Bytes()) + var outCBOR bucket.InfoOK + require.NoError(t, outCBOR.UnmarshalCBOR(bytes.NewReader(cb.Bytes()))) + require.Nil(t, outCBOR.Principal) + + var jb bytes.Buffer + require.NoError(t, in.MarshalDagJSON(&jb)) + require.NotContains(t, jb.String(), "principal") + var outJSON bucket.InfoOK + require.NoError(t, outJSON.UnmarshalDagJSON(bytes.NewReader(jb.Bytes())), "json: %s", jb.String()) + require.Nil(t, outJSON.Principal) + }) + + t.Run("set value survives", func(t *testing.T) { + in := base() + in.Principal = ptr("8f2c9e14") + + var cb bytes.Buffer + require.NoError(t, in.MarshalCBOR(&cb)) + var outCBOR bucket.InfoOK + require.NoError(t, outCBOR.UnmarshalCBOR(bytes.NewReader(cb.Bytes()))) + require.Equal(t, in.Principal, outCBOR.Principal) + + var jb bytes.Buffer + require.NoError(t, in.MarshalDagJSON(&jb)) + require.Contains(t, jb.String(), `"principal":"8f2c9e14"`) + var outJSON bucket.InfoOK + require.NoError(t, outJSON.UnmarshalDagJSON(bytes.NewReader(jb.Bytes())), "json: %s", jb.String()) + require.Equal(t, in.Principal, outJSON.Principal) + }) +} diff --git a/commands/s3/request/cbor_gen.go b/commands/s3/request/cbor_gen.go index 4a6eab0..67f7d85 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.Principal == nil { + fieldCount-- + } + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { return err } @@ -181,6 +185,38 @@ func (t *AuthorizeOK) MarshalCBOR(w io.Writer) error { return err } + // t.Principal (string) (string) + if t.Principal != nil { + + if len("principal") > 8192 { + return xerrors.Errorf("Value in field \"principal\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("principal"))); err != nil { + return err + } + if _, err := cw.WriteString(string("principal")); err != nil { + return err + } + + if t.Principal == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.Principal) > 8192 { + return xerrors.Errorf("Value in field t.Principal was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Principal))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.Principal)); err != nil { + return err + } + } + } + // t.Delegations (s3.ProofSet) (struct) if len("delegations") > 8192 { return xerrors.Errorf("Value in field \"delegations\" was too long") @@ -296,6 +332,27 @@ func (t *AuthorizeOK) UnmarshalCBOR(r io.Reader) (err error) { } } + // t.Principal (string) (string) + case "principal": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + t.Principal = (*string)(&sval) + } + } // t.Delegations (s3.ProofSet) (struct) case "delegations": diff --git a/commands/s3/request/json_gen.go b/commands/s3/request/json_gen.go index 33c3669..bb6a137 100644 --- a/commands/s3/request/json_gen.go +++ b/commands/s3/request/json_gen.go @@ -199,6 +199,39 @@ func (t *AuthorizeOK) MarshalDagJSON(w io.Writer) error { return fmt.Errorf("marshaling field t.Permissions: %w", err) } written = true + if t.Principal != nil { + if written { + if err := jw.WriteComma(); err != nil { + return err + } + } + } + + // t.Principal (string) (string) + if t.Principal != nil { + if len("principal") > 8192 { + return fmt.Errorf("string in field \"principal\" was too long") + } + if err := jw.WriteString(string("principal")); err != nil { + return fmt.Errorf("writing string for field \"principal\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if t.Principal == nil { + if err := jw.WriteNull(); err != nil { + return fmt.Errorf("writing null for field t.Principal: %w", err) + } + } else { + if len(*t.Principal) > 8192 { + return fmt.Errorf("string in field t.Principal was too long") + } + if err := jw.WriteString(string(*t.Principal)); err != nil { + return fmt.Errorf("writing string for field t.Principal: %w", err) + } + } + written = true + } if written { if err := jw.WriteComma(); err != nil { return err @@ -298,6 +331,21 @@ func (t *AuthorizeOK) UnmarshalDagJSON(r io.Reader) (err error) { return fmt.Errorf("unmarshaling t.Permissions: %w", err) } + // t.Principal (string) (string) + case "principal": + { + sval, err := jr.ReadStringOrNull(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("reading string or null for field t.Principal: string too long") + } + return fmt.Errorf("reading string or null for field t.Principal: %w", err) + } + if sval != nil { + t.Principal = (*string)(sval) + } + } + // t.Tenant (did.DID) (struct) case "tenant": diff --git a/commands/s3/request/types.go b/commands/s3/request/types.go index cf806ac..ebb51c9 100644 --- a/commands/s3/request/types.go +++ b/commands/s3/request/types.go @@ -29,6 +29,10 @@ type AuthorizeOK struct { // 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. Tenant did.DID `cborgen:"tenant" dagjsongen:"tenant"` + // Principal is the identifier of the principal the access key is bound to, + // unique within the tenant. It is nil for a service key, which carries its + // own permissions and buckets and is bound to no principal. + Principal *string `cborgen:"principal,omitempty" dagjsongen:"principal,omitempty"` // Permissions maps the access key DID to its assigned S3 permissions. Permissions s3.PermissionSet `cborgen:"permissions" dagjsongen:"permissions"` // Keys maps the access key DID to its derived signing key(s).