diff --git a/commands/s3/bucket/cbor_gen.go b/commands/s3/bucket/cbor_gen.go index cb7caff..b532efe 100644 --- a/commands/s3/bucket/cbor_gen.go +++ b/commands/s3/bucket/cbor_gen.go @@ -10,6 +10,7 @@ import ( "math" "sort" + commands "github.com/fil-forge/libforge/commands" cid "github.com/ipfs/go-cid" cbg "github.com/whyrusleeping/cbor-gen" xerrors "golang.org/x/xerrors" @@ -296,6 +297,664 @@ func (t *ListArguments) UnmarshalCBOR(r io.Reader) (err error) { return nil } +func (t *PolicyArguments) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{162}); err != nil { + return err + } + + // t.Body ([]uint8) (slice) + if len("body") > 8192 { + return xerrors.Errorf("Value in field \"body\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { + return err + } + if _, err := cw.WriteString(string("body")); err != nil { + return err + } + + if len(t.Body) > 2097152 { + return xerrors.Errorf("Byte array in field t.Body was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajByteString, uint64(len(t.Body))); err != nil { + return err + } + + if _, err := cw.Write(t.Body); err != nil { + return err + } + + // t.Request (s3.Request) (struct) + if len("request") > 8192 { + return xerrors.Errorf("Value in field \"request\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("request"))); err != nil { + return err + } + if _, err := cw.WriteString(string("request")); err != nil { + return err + } + + if err := t.Request.MarshalCBOR(cw); err != nil { + return err + } + return nil +} + +func (t *PolicyArguments) UnmarshalCBOR(r io.Reader) (err error) { + *t = PolicyArguments{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("PolicyArguments: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 7) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Body ([]uint8) (slice) + case "body": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 2097152 { + return fmt.Errorf("t.Body: byte array too large (%d)", extra) + } + if maj != cbg.MajByteString { + return fmt.Errorf("expected byte array") + } + + if extra > 0 { + t.Body = make([]uint8, extra) + } + + if _, err := io.ReadFull(cr, t.Body); err != nil { + return err + } + + // t.Request (s3.Request) (struct) + case "request": + + { + + if err := t.Request.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Request: %w", err) + } + + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} +func (t *PolicyOK) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + fieldCount := 1 + + if t.Policy == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { + return err + } + + // t.Policy (bucket.PolicyDocument) (struct) + if t.Policy != nil { + + if len("policy") > 8192 { + return xerrors.Errorf("Value in field \"policy\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("policy"))); err != nil { + return err + } + if _, err := cw.WriteString(string("policy")); err != nil { + return err + } + + if err := t.Policy.MarshalCBOR(cw); err != nil { + return err + } + } + return nil +} + +func (t *PolicyOK) UnmarshalCBOR(r io.Reader) (err error) { + *t = PolicyOK{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("PolicyOK: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 6) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Policy (bucket.PolicyDocument) (struct) + case "policy": + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Policy = new(PolicyDocument) + if err := t.Policy.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Policy pointer: %w", err) + } + } + + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} +func (t *Statement) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + fieldCount := 5 + + if t.Sid == "" { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { + return err + } + + // t.Sid (string) (string) + if t.Sid != "" { + + if len("sid") > 8192 { + return xerrors.Errorf("Value in field \"sid\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sid"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sid")); err != nil { + return err + } + + if len(t.Sid) > 8192 { + return xerrors.Errorf("Value in field t.Sid was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Sid))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Sid)); err != nil { + return err + } + } + + // t.Actions (bucket.StringSet) (map) + if len("action") > 8192 { + return xerrors.Errorf("Value in field \"action\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("action"))); err != nil { + return err + } + if _, err := cw.WriteString(string("action")); err != nil { + return err + } + + { + if len(t.Actions) > 4096 { + return xerrors.Errorf("cannot marshal t.Actions map too large") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajMap, uint64(len(t.Actions))); err != nil { + return err + } + + keys := make([]string, 0, len(t.Actions)) + for k := range t.Actions { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := t.Actions[k] + + if len(k) > 8192 { + return xerrors.Errorf("Value in field k was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(k))); err != nil { + return err + } + if _, err := cw.WriteString(string(k)); err != nil { + return err + } + + if err := v.MarshalCBOR(cw); err != nil { + return err + } + + } + } + + // t.Effect (bucket.Effect) (string) + if len("effect") > 8192 { + return xerrors.Errorf("Value in field \"effect\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("effect"))); err != nil { + return err + } + if _, err := cw.WriteString(string("effect")); err != nil { + return err + } + + if len(t.Effect) > 8192 { + return xerrors.Errorf("Value in field t.Effect was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Effect))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Effect)); err != nil { + return err + } + + // t.Resources (bucket.StringSet) (map) + if len("resource") > 8192 { + return xerrors.Errorf("Value in field \"resource\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("resource"))); err != nil { + return err + } + if _, err := cw.WriteString(string("resource")); err != nil { + return err + } + + { + if len(t.Resources) > 4096 { + return xerrors.Errorf("cannot marshal t.Resources map too large") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajMap, uint64(len(t.Resources))); err != nil { + return err + } + + keys := make([]string, 0, len(t.Resources)) + for k := range t.Resources { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := t.Resources[k] + + if len(k) > 8192 { + return xerrors.Errorf("Value in field k was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(k))); err != nil { + return err + } + if _, err := cw.WriteString(string(k)); err != nil { + return err + } + + if err := v.MarshalCBOR(cw); err != nil { + return err + } + + } + } + + // t.Principal (bucket.Principal) (map) + 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 len(t.Principal) > 4096 { + return xerrors.Errorf("cannot marshal t.Principal map too large") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajMap, uint64(len(t.Principal))); err != nil { + return err + } + + keys := make([]string, 0, len(t.Principal)) + for k := range t.Principal { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := t.Principal[k] + + if len(k) > 8192 { + return xerrors.Errorf("Value in field k was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(k))); err != nil { + return err + } + if _, err := cw.WriteString(string(k)); err != nil { + return err + } + + if err := v.MarshalCBOR(cw); err != nil { + return err + } + + } + } + return nil +} + +func (t *Statement) UnmarshalCBOR(r io.Reader) (err error) { + *t = Statement{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("Statement: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 9) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Sid (string) (string) + case "sid": + + { + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + t.Sid = string(sval) + } + // t.Actions (bucket.StringSet) (map) + case "action": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + if maj != cbg.MajMap { + return fmt.Errorf("expected a map (major type 5)") + } + if extra > 4096 { + return fmt.Errorf("t.Actions: map too large") + } + + t.Actions = make(map[string]commands.Unit, extra) + + for i, l := 0, int(extra); i < l; i++ { + + var k string + + { + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + k = string(sval) + } + + var v commands.Unit + + { + + if err := v.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling v: %w", err) + } + + } + + t.Actions[k] = v + + } + // t.Effect (bucket.Effect) (string) + case "effect": + + { + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + t.Effect = Effect(sval) + } + // t.Resources (bucket.StringSet) (map) + case "resource": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + if maj != cbg.MajMap { + return fmt.Errorf("expected a map (major type 5)") + } + if extra > 4096 { + return fmt.Errorf("t.Resources: map too large") + } + + t.Resources = make(map[string]commands.Unit, extra) + + for i, l := 0, int(extra); i < l; i++ { + + var k string + + { + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + k = string(sval) + } + + var v commands.Unit + + { + + if err := v.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling v: %w", err) + } + + } + + t.Resources[k] = v + + } + // t.Principal (bucket.Principal) (map) + case "principal": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + if maj != cbg.MajMap { + return fmt.Errorf("expected a map (major type 5)") + } + if extra > 4096 { + return fmt.Errorf("t.Principal: map too large") + } + + t.Principal = make(map[string]commands.Unit, extra) + + for i, l := 0, int(extra); i < l; i++ { + + var k string + + { + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + k = string(sval) + } + + var v commands.Unit + + { + + if err := v.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling v: %w", err) + } + + } + + t.Principal[k] = v + + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} func (t *InfoArguments) MarshalCBOR(w io.Writer) error { if t == nil { _, err := w.Write(cbg.CborNull) diff --git a/commands/s3/bucket/etag.go b/commands/s3/bucket/etag.go new file mode 100644 index 0000000..5289fbd --- /dev/null +++ b/commands/s3/bucket/etag.go @@ -0,0 +1,39 @@ +//go:build !codegen + +// The functions below call generated encoders, which the generator builds +// the package without. + +package bucket + +import ( + "bytes" + "io" + + "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" +) + +// ETag returns the entity tag of d: the CID of its DAG-JSON encoding (CIDv1, +// dag-json, sha2-256), in double quotes as HTTP carries it. The encoding is +// canonical (see [StatementSet]), so two documents that differ only in +// statement order or duplicates share a tag. Hilt stores it with the policy +// and checks If-Match against it; Ingot answers GetBucketPolicy and +// PutBucketPolicy with it. Callers treat it as opaque. +func ETag(d PolicyDocument) string { return `"` + cidOf(&d).String() + `"` } + +// StatementCID returns the CID of st's DAG-JSON encoding: its sets as maps +// with sorted keys, so nothing about how the caller built them reaches it. +func StatementCID(st Statement) cid.Cid { return cidOf(&st) } + +// cidOf returns the CIDv1 (dag-json, sha2-256) of m's DAG-JSON encoding. +func cidOf(m interface{ MarshalDagJSON(io.Writer) error }) cid.Cid { + var buf bytes.Buffer + if err := m.MarshalDagJSON(&buf); err != nil { + panic(err) // the sets are bounded by MaxSetSize, within the encoders' limits + } + sum, err := mh.Sum(buf.Bytes(), mh.SHA2_256, -1) + if err != nil { + panic(err) // sha2-256 with the default length cannot fail + } + return cid.NewCidV1(cid.DagJSON, sum) +} diff --git a/commands/s3/bucket/etag_test.go b/commands/s3/bucket/etag_test.go new file mode 100644 index 0000000..5b0dd53 --- /dev/null +++ b/commands/s3/bucket/etag_test.go @@ -0,0 +1,221 @@ +package bucket_test + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "slices" + "strings" + "testing" + + "github.com/fil-forge/libforge/commands/s3/bucket" + "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" + "github.com/stretchr/testify/require" +) + +const ( + bucketARN = "arn:aws:s3:::photos" + objectsARN = "arn:aws:s3:::photos/*" +) + +var everyone = bucket.Everyone() + +func only(ids ...string) bucket.Principal { return bucket.Only(ids...) } + +// both is the resource set every helper statement carries. +func both() bucket.StringSet { return bucket.NewStringSet(bucketARN, objectsARN) } + +func allow(p bucket.Principal, actions ...string) bucket.Statement { + return bucket.Statement{Effect: bucket.Allow, Principal: p, Actions: bucket.NewStringSet(actions...), Resources: both()} +} + +func deny(p bucket.Principal, actions ...string) bucket.Statement { + return bucket.Statement{Effect: bucket.Deny, Principal: p, Actions: bucket.NewStringSet(actions...), Resources: both()} +} + +func doc(statements ...bucket.Statement) bucket.PolicyDocument { + return bucket.PolicyDocument{Version: bucket.Version2012, Statements: statements} +} + +func TestStatementSet(t *testing.T) { + a := doc(allow(only("alice"), "s3:GetObject"), deny(everyone, "s3:DeleteObject")) + b := doc(deny(everyone, "s3:DeleteObject"), allow(only("alice"), "s3:GetObject"), deny(everyone, "s3:DeleteObject")) + + t.Run("every encoding orders statements by CID and drops duplicates", func(t *testing.T) { + ja, err := json.Marshal(a) + require.NoError(t, err) + jb, err := json.Marshal(b) + require.NoError(t, err) + require.Equal(t, string(ja), string(jb)) + require.Equal(t, 2, strings.Count(string(jb), `"Effect"`)) + + var da, db, ca, cb bytes.Buffer + require.NoError(t, a.MarshalDagJSON(&da)) + require.NoError(t, b.MarshalDagJSON(&db)) + require.Equal(t, da.String(), db.String()) + require.NoError(t, a.MarshalCBOR(&ca)) + require.NoError(t, b.MarshalCBOR(&cb)) + require.Equal(t, ca.Bytes(), cb.Bytes()) + + // The in-memory order is the caller's. + require.Equal(t, bucket.Deny, b.Statements[0].Effect) + require.Len(t, b.Statements, 3) + }) + + t.Run("decoding keeps the wire order, which is canonical", func(t *testing.T) { + var cb bytes.Buffer + require.NoError(t, b.MarshalCBOR(&cb)) + var back bucket.PolicyDocument + require.NoError(t, back.UnmarshalCBOR(bytes.NewReader(cb.Bytes()))) + require.Len(t, back.Statements, 2) + require.True(t, bucket.StatementCID(back.Statements[0]).String() < bucket.StatementCID(back.Statements[1]).String()) + require.Equal(t, a.Version, back.Version) + + var jb bytes.Buffer + require.NoError(t, b.MarshalDagJSON(&jb)) + var backJSON bucket.PolicyDocument + require.NoError(t, backJSON.UnmarshalDagJSON(bytes.NewReader(jb.Bytes()))) + require.Equal(t, back, backJSON) + }) + + t.Run("nil and empty encode the same", func(t *testing.T) { + var n1, n2 bytes.Buffer + require.NoError(t, (&bucket.PolicyDocument{}).MarshalDagJSON(&n1)) + require.NoError(t, (&bucket.PolicyDocument{Statements: bucket.StatementSet{}}).MarshalDagJSON(&n2)) + require.Equal(t, `{"statement":[],"version":""}`, n1.String()) + require.Equal(t, n1.String(), n2.String()) + raw, err := json.Marshal(bucket.PolicyDocument{}) + require.NoError(t, err) + require.Equal(t, `{"Version":"","Statement":[]}`, string(raw)) + }) + + t.Run("CBOR and DAG-JSON match the generated layout", func(t *testing.T) { + // Captured from the generated encoders before the codec was hand-written: + // CBOR keys shortest first (version, statement), DAG-JSON keys sorted. + const cborHex = "a26776657273696f6e6a323031322d31302d31376973746174656d656e7481a466616374696f6ea16c73333a4765744f626a656374a06665666665637465416c6c6f77687265736f75726365a27361726e3a6177733a73333a3a3a70686f746f73a07561726e3a6177733a73333a3a3a70686f746f732f2aa0697072696e636970616ca165616c696365a0" + const okCBORHex = "a166706f6c696379" + cborHex + d := doc(allow(only("alice"), "s3:GetObject")) + var cb bytes.Buffer + require.NoError(t, d.MarshalCBOR(&cb)) + require.Equal(t, cborHex, hex.EncodeToString(cb.Bytes())) + var back bucket.PolicyDocument + require.NoError(t, back.UnmarshalCBOR(bytes.NewReader(cb.Bytes()))) + require.Equal(t, d, back) + + ok := bucket.PolicyOK{Policy: &d} + cb.Reset() + require.NoError(t, ok.MarshalCBOR(&cb)) + require.Equal(t, okCBORHex, hex.EncodeToString(cb.Bytes())) + + var jb bytes.Buffer + require.NoError(t, d.MarshalDagJSON(&jb)) + require.Equal(t, `{"statement":[{"action":{"s3:GetObject":{}},"effect":"Allow","principal":{"alice":{}},"resource":{"arn:aws:s3:::photos":{},"arn:aws:s3:::photos/*":{}}}],"version":"2012-10-17"}`, jb.String()) + }) +} + +func TestETag(t *testing.T) { + d := doc(allow(only("alice"), "s3:GetObject")) + + t.Run("is the quoted dag-json CID of the normalized document", func(t *testing.T) { + tag := bucket.ETag(d) + require.Regexp(t, `^"bagu[a-z2-7]+"$`, tag) + c, err := cid.Decode(tag[1 : len(tag)-1]) + require.NoError(t, err) + require.EqualValues(t, cid.DagJSON, c.Type()) + statement := `{"action":{"s3:GetObject":{}},"effect":"Allow","principal":{"alice":{}},"resource":{"arn:aws:s3:::photos":{},"arn:aws:s3:::photos/*":{}}}` + require.Equal(t, dagJSONCID(t, dagJSONPolicy(t, "2012-10-17", statement)), c) + }) + + t.Run("encodes a sid, a wildcard principal and several statements", func(t *testing.T) { + d := doc( + bucket.Statement{Sid: "owners", Effect: bucket.Allow, Principal: only("bob", "alice"), Actions: bucket.NewStringSet("s3:ListBucket", "s3:GetObject"), Resources: both()}, + bucket.Statement{Effect: bucket.Deny, Principal: everyone, Actions: bucket.NewStringSet("s3:PutObjectRetention"), Resources: bucket.NewStringSet(objectsARN)}, + ) + want := dagJSONPolicy(t, "2012-10-17", + dagJSONStatement("owners", bucket.Allow, []string{"alice", "bob"}, []string{"s3:GetObject", "s3:ListBucket"}, both().Members()), + dagJSONStatement("", bucket.Deny, []string{"*"}, []string{"s3:PutObjectRetention"}, []string{objectsARN}), + ) + require.Equal(t, `"`+dagJSONCID(t, want).String()+`"`, bucket.ETag(d)) + }) + + t.Run("order and duplicates are not significant", func(t *testing.T) { + a := doc(allow(only("alice", "bob"), "s3:GetObject", "s3:PutObject"), deny(everyone, "s3:DeleteObject")) + dup := deny(everyone, "s3:DeleteObject") + dup.Resources = bucket.NewStringSet(objectsARN, bucketARN, bucketARN) + b := doc( + deny(everyone, "s3:DeleteObject"), + allow(only("bob", "alice", "bob"), "s3:PutObject", "s3:GetObject", "s3:PutObject"), + dup, + ) + require.Equal(t, bucket.ETag(a), bucket.ETag(b)) + }) + + t.Run("is stable and changes with the document", func(t *testing.T) { + require.Equal(t, bucket.ETag(d), bucket.ETag(doc(allow(only("alice"), "s3:GetObject")))) + require.NotEqual(t, bucket.ETag(d), bucket.ETag(doc(allow(only("alice"), "s3:PutObject")))) + require.NotEqual(t, bucket.ETag(d), bucket.ETag(doc(allow(only("bob"), "s3:GetObject")))) + require.NotEqual(t, bucket.ETag(d), bucket.ETag(doc(deny(only("alice"), "s3:GetObject")))) + withSid := doc(bucket.Statement{Sid: "read", Effect: bucket.Allow, Principal: only("alice"), Actions: bucket.NewStringSet("s3:GetObject"), Resources: both()}) + require.NotEqual(t, bucket.ETag(d), bucket.ETag(withSid)) + objectsOnly := doc(bucket.Statement{Effect: bucket.Allow, Principal: only("alice"), Actions: bucket.NewStringSet("s3:GetObject"), Resources: bucket.NewStringSet(objectsARN)}) + require.NotEqual(t, bucket.ETag(d), bucket.ETag(objectsOnly)) + older := d + older.Version = bucket.Version2008 + require.NotEqual(t, bucket.ETag(d), bucket.ETag(older)) + }) + + t.Run("pins the canonical hashes", func(t *testing.T) { + // Hilt compares stored tags against freshly computed ones for If-Match, + // and Ingot answers clients with freshly computed ones, so these values + // must never change without a deliberate decision. Both were computed + // from hand-assembled DAG-JSON bytes: + // {"statement":[],"version":""} and + // {"statement":[],"version":"2012-10-17"}. + require.Equal(t, `"baguqeerapfhqlt74verbk25awofci4fsi4dewjbihdbnexfwphaywy4srd2q"`, bucket.ETag(bucket.PolicyDocument{})) + require.Equal(t, `"baguqeeramb7bdvrj6whijwstk5ktmw3z3wrwg64cf4xfj6qb2xxgsxsj7emq"`, bucket.ETag(d)) + require.Equal(t, bucket.ETag(bucket.PolicyDocument{}), bucket.ETag(bucket.PolicyDocument{Statements: []bucket.Statement{}})) + require.Equal(t, `"`+dagJSONCID(t, `{"statement":[],"version":""}`).String()+`"`, bucket.ETag(bucket.PolicyDocument{})) + }) +} + +// dagJSONCID is the CID the ETag is built from: CIDv1, dag-json, sha2-256 +// over raw, which the tests assemble by hand so that the pins do not depend +// on the generated encoder. +func dagJSONCID(t *testing.T, raw string) cid.Cid { + t.Helper() + sum, err := mh.Sum([]byte(raw), mh.SHA2_256, -1) + require.NoError(t, err) + return cid.NewCidV1(cid.DagJSON, sum) +} + +// dagJSONSet is the DAG-JSON encoding of a set: an object whose keys are the +// members, sorted, each mapped to {}. +func dagJSONSet(members ...string) string { + members = slices.Clone(members) + slices.Sort(members) + for i, m := range members { + members[i] = `"` + m + `":{}` + } + return "{" + strings.Join(members, ",") + "}" +} + +// dagJSONStatement is the DAG-JSON encoding of a statement: keys in sorted +// order, sid omitted when empty, principals, actions and resources as sets, +// the wildcard principal as the set {"*"}. +func dagJSONStatement(sid string, effect bucket.Effect, principals, actions, resources []string) string { + raw := `{"action":` + dagJSONSet(actions...) + `,"effect":"` + string(effect) + `","principal":` + dagJSONSet(principals...) + `,"resource":` + dagJSONSet(resources...) + if sid != "" { + raw += `,"sid":"` + sid + `"` + } + return raw + "}" +} + +// dagJSONPolicy is the DAG-JSON encoding of a policy: its statements as a +// list in the order of their CIDs, then its version. +func dagJSONPolicy(t *testing.T, version string, statements ...string) string { + t.Helper() + statements = slices.Clone(statements) + slices.SortFunc(statements, func(a, b string) int { return strings.Compare(dagJSONCID(t, a).String(), dagJSONCID(t, b).String()) }) + return `{"statement":[` + strings.Join(statements, ",") + `],"version":"` + version + `"}` +} diff --git a/commands/s3/bucket/gen/main.go b/commands/s3/bucket/gen/main.go index 2f464bc..a4d70d4 100644 --- a/commands/s3/bucket/gen/main.go +++ b/commands/s3/bucket/gen/main.go @@ -27,6 +27,9 @@ func main() { bucket.CreateArguments{}, bucket.DeleteArguments{}, bucket.ListArguments{}, + bucket.PolicyArguments{}, + bucket.PolicyOK{}, + bucket.Statement{}, bucket.InfoArguments{}, bucket.InfoOK{}, bucket.ListOK{}, diff --git a/commands/s3/bucket/json_gen.go b/commands/s3/bucket/json_gen.go index 16d1db1..76c4965 100644 --- a/commands/s3/bucket/json_gen.go +++ b/commands/s3/bucket/json_gen.go @@ -12,6 +12,7 @@ import ( "sort" jsg "github.com/alanshaw/dag-json-gen" + commands "github.com/fil-forge/libforge/commands" cid "github.com/ipfs/go-cid" ) @@ -293,6 +294,703 @@ func (t *ListArguments) UnmarshalDagJSON(r io.Reader) (err error) { return nil } +func (t *PolicyArguments) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if t == nil { + err := jw.WriteNull() + return err + } + if err := jw.WriteObjectOpen(); err != nil { + return err + } + written := false + + // t.Body ([]uint8) (slice) + if len("body") > 8192 { + return fmt.Errorf("string in field \"body\" was too long") + } + if err := jw.WriteString(string("body")); err != nil { + return fmt.Errorf("writing string for field \"body\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if len(t.Body) > 2097152 { + return fmt.Errorf("byte array in field t.Body was too long") + } + + if err := jw.WriteBytes(t.Body); err != nil { + return fmt.Errorf("writing bytes for field t.Body: %w", err) + } + + written = true + if written { + if err := jw.WriteComma(); err != nil { + return err + } + } + + // t.Request (s3.Request) (struct) + if len("request") > 8192 { + return fmt.Errorf("string in field \"request\" was too long") + } + if err := jw.WriteString(string("request")); err != nil { + return fmt.Errorf("writing string for field \"request\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if err := t.Request.MarshalDagJSON(jw); err != nil { + return fmt.Errorf("marshaling field t.Request: %w", err) + } + if err := jw.WriteObjectClose(); err != nil { + return err + } + return nil +} +func (t *PolicyArguments) UnmarshalDagJSON(r io.Reader) (err error) { + *t = PolicyArguments{} + + jr := jsg.NewDagJsonReader(r) + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("reading object open for PolicyArguments: %w", err) + } + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("peeking object close for PolicyArguments: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("reading object close for PolicyArguments: %w", err) + } + } else { + for i := uint64(0); i < 8192; i++ { + name, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("reading string for field PolicyArguments: string too large") + } + return fmt.Errorf("reading string for field PolicyArguments: %w", err) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("reading object colon for field PolicyArguments: %w", err) + } + switch name { + + // t.Body ([]uint8) (slice) + case "body": + + { + bval, err := jr.ReadBytes(2097152) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("reading bytes for field t.Body: byte array too large") + } + return fmt.Errorf("reading bytes for field t.Body: %w", err) + } + if len(bval) > 0 { + t.Body = []uint8(bval) + } + } + + // t.Request (s3.Request) (struct) + case "request": + + if err := t.Request.UnmarshalDagJSON(jr); err != nil { + return fmt.Errorf("unmarshaling t.Request: %w", err) + } + + default: + // Field doesn't exist on this type, so ignore it + if err := jr.DiscardType(); err != nil { + return fmt.Errorf("ignoring field %s for PolicyArguments: %w", name, err) + } + } + + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("reading object close or comma for field PolicyArguments: %w", err) + } + if close { + break + } + if i == 8192-1 { + return fmt.Errorf("map too large for PolicyArguments") + } + } + } + + return nil +} +func (t *PolicyOK) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if t == nil { + err := jw.WriteNull() + return err + } + if err := jw.WriteObjectOpen(); err != nil { + return err + } + + // t.Policy (bucket.PolicyDocument) (struct) + if t.Policy != nil { + if len("policy") > 8192 { + return fmt.Errorf("string in field \"policy\" was too long") + } + if err := jw.WriteString(string("policy")); err != nil { + return fmt.Errorf("writing string for field \"policy\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if err := t.Policy.MarshalDagJSON(jw); err != nil { + return fmt.Errorf("marshaling field t.Policy: %w", err) + } + } + if err := jw.WriteObjectClose(); err != nil { + return err + } + return nil +} +func (t *PolicyOK) UnmarshalDagJSON(r io.Reader) (err error) { + *t = PolicyOK{} + + jr := jsg.NewDagJsonReader(r) + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("reading object open for PolicyOK: %w", err) + } + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("peeking object close for PolicyOK: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("reading object close for PolicyOK: %w", err) + } + } else { + for i := uint64(0); i < 8192; i++ { + name, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("reading string for field PolicyOK: string too large") + } + return fmt.Errorf("reading string for field PolicyOK: %w", err) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("reading object colon for field PolicyOK: %w", err) + } + switch name { + + // t.Policy (bucket.PolicyDocument) (struct) + case "policy": + + { + null, err := jr.PeekNull() + if err != nil { + return fmt.Errorf("peeking null for field t.Policy: %w", err) + } + if null { + if err := jr.ReadNull(); err != nil { + return fmt.Errorf("reading null for field t.Policy: %w", err) + } + } else { + t.Policy = new(PolicyDocument) + if err := t.Policy.UnmarshalDagJSON(jr); err != nil { + return fmt.Errorf("unmarshaling t.Policy pointer: %w", err) + } + } + } + + default: + // Field doesn't exist on this type, so ignore it + if err := jr.DiscardType(); err != nil { + return fmt.Errorf("ignoring field %s for PolicyOK: %w", name, err) + } + } + + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("reading object close or comma for field PolicyOK: %w", err) + } + if close { + break + } + if i == 8192-1 { + return fmt.Errorf("map too large for PolicyOK") + } + } + } + + return nil +} +func (t *Statement) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if t == nil { + err := jw.WriteNull() + return err + } + if err := jw.WriteObjectOpen(); err != nil { + return err + } + written := false + + // t.Actions (bucket.StringSet) (map) + if len("action") > 8192 { + return fmt.Errorf("string in field \"action\" was too long") + } + if err := jw.WriteString(string("action")); err != nil { + return fmt.Errorf("writing string for field \"action\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + { + if len(t.Actions) > 4096 { + return fmt.Errorf("cannot marshal t.Actions map too large") + } + + if err := jw.WriteObjectOpen(); err != nil { + return fmt.Errorf("writing object open for field t.Actions: %w", err) + } + + keys := make([]string, 0, len(t.Actions)) + for k := range t.Actions { + keys = append(keys, k) + } + sort.Strings(keys) + for i, k := range keys { + if i > 0 { + if err := jw.WriteComma(); err != nil { + return fmt.Errorf("writing comma for field t.Actions: %w", err) + } + } + v := t.Actions[k] + if len(k) > 8192 { + return fmt.Errorf("string in field k was too long") + } + if err := jw.WriteString(string(k)); err != nil { + return fmt.Errorf("writing string for field k: %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return fmt.Errorf("writing colon for field t.Actions: %w", err) + } + + if err := v.MarshalDagJSON(jw); err != nil { + return fmt.Errorf("marshaling field v: %w", err) + } + } + if err := jw.WriteObjectClose(); err != nil { + return fmt.Errorf("writing object close for field t.Actions: %w", err) + } + } + + written = true + if written { + if err := jw.WriteComma(); err != nil { + return err + } + } + + // t.Effect (bucket.Effect) (string) + if len("effect") > 8192 { + return fmt.Errorf("string in field \"effect\" was too long") + } + if err := jw.WriteString(string("effect")); err != nil { + return fmt.Errorf("writing string for field \"effect\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if len(t.Effect) > 8192 { + return fmt.Errorf("string in field t.Effect was too long") + } + if err := jw.WriteString(string(t.Effect)); err != nil { + return fmt.Errorf("writing string for field t.Effect: %w", err) + } + written = true + if written { + if err := jw.WriteComma(); err != nil { + return err + } + } + + // t.Principal (bucket.Principal) (map) + 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 len(t.Principal) > 4096 { + return fmt.Errorf("cannot marshal t.Principal map too large") + } + + if err := jw.WriteObjectOpen(); err != nil { + return fmt.Errorf("writing object open for field t.Principal: %w", err) + } + + keys := make([]string, 0, len(t.Principal)) + for k := range t.Principal { + keys = append(keys, k) + } + sort.Strings(keys) + for i, k := range keys { + if i > 0 { + if err := jw.WriteComma(); err != nil { + return fmt.Errorf("writing comma for field t.Principal: %w", err) + } + } + v := t.Principal[k] + if len(k) > 8192 { + return fmt.Errorf("string in field k was too long") + } + if err := jw.WriteString(string(k)); err != nil { + return fmt.Errorf("writing string for field k: %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return fmt.Errorf("writing colon for field t.Principal: %w", err) + } + + if err := v.MarshalDagJSON(jw); err != nil { + return fmt.Errorf("marshaling field v: %w", err) + } + } + if err := jw.WriteObjectClose(); err != nil { + return fmt.Errorf("writing object close for field t.Principal: %w", err) + } + } + + written = true + if written { + if err := jw.WriteComma(); err != nil { + return err + } + } + + // t.Resources (bucket.StringSet) (map) + if len("resource") > 8192 { + return fmt.Errorf("string in field \"resource\" was too long") + } + if err := jw.WriteString(string("resource")); err != nil { + return fmt.Errorf("writing string for field \"resource\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + { + if len(t.Resources) > 4096 { + return fmt.Errorf("cannot marshal t.Resources map too large") + } + + if err := jw.WriteObjectOpen(); err != nil { + return fmt.Errorf("writing object open for field t.Resources: %w", err) + } + + keys := make([]string, 0, len(t.Resources)) + for k := range t.Resources { + keys = append(keys, k) + } + sort.Strings(keys) + for i, k := range keys { + if i > 0 { + if err := jw.WriteComma(); err != nil { + return fmt.Errorf("writing comma for field t.Resources: %w", err) + } + } + v := t.Resources[k] + if len(k) > 8192 { + return fmt.Errorf("string in field k was too long") + } + if err := jw.WriteString(string(k)); err != nil { + return fmt.Errorf("writing string for field k: %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return fmt.Errorf("writing colon for field t.Resources: %w", err) + } + + if err := v.MarshalDagJSON(jw); err != nil { + return fmt.Errorf("marshaling field v: %w", err) + } + } + if err := jw.WriteObjectClose(); err != nil { + return fmt.Errorf("writing object close for field t.Resources: %w", err) + } + } + + written = true + if t.Sid != "" { + if written { + if err := jw.WriteComma(); err != nil { + return err + } + } + } + + // t.Sid (string) (string) + if t.Sid != "" { + if len("sid") > 8192 { + return fmt.Errorf("string in field \"sid\" was too long") + } + if err := jw.WriteString(string("sid")); err != nil { + return fmt.Errorf("writing string for field \"sid\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if len(t.Sid) > 8192 { + return fmt.Errorf("string in field t.Sid was too long") + } + if err := jw.WriteString(string(t.Sid)); err != nil { + return fmt.Errorf("writing string for field t.Sid: %w", err) + } + } + if err := jw.WriteObjectClose(); err != nil { + return err + } + return nil +} +func (t *Statement) UnmarshalDagJSON(r io.Reader) (err error) { + *t = Statement{} + + jr := jsg.NewDagJsonReader(r) + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("reading object open for Statement: %w", err) + } + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("peeking object close for Statement: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("reading object close for Statement: %w", err) + } + } else { + for i := uint64(0); i < 8192; i++ { + name, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("reading string for field Statement: string too large") + } + return fmt.Errorf("reading string for field Statement: %w", err) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("reading object colon for field Statement: %w", err) + } + switch name { + + // t.Actions (bucket.StringSet) (map) + case "action": + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("reading object open for field t.Actions: %w", err) + } + + t.Actions = map[string]commands.Unit{} + + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("peeking object close for field t.Actions: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("reading object close for field t.Actions: %w", err) + } + } else { + for i, l := 0, 8192; i < l; i++ { + var k string + { + sval, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("reading string for field k: string too long") + } + return fmt.Errorf("reading string for field k: %w", err) + } + k = string(sval) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("reading object colon for field t.Actions: %w", err) + } + var v commands.Unit + + if err := v.UnmarshalDagJSON(jr); err != nil { + return fmt.Errorf("unmarshaling v: %w", err) + } + + t.Actions[k] = v + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("reading object close or comma for field t.Actions: %w", err) + } + if close { + break + } + } + } + + // t.Effect (bucket.Effect) (string) + case "effect": + { + sval, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("reading string for field t.Effect: string too long") + } + return fmt.Errorf("reading string for field t.Effect: %w", err) + } + t.Effect = Effect(sval) + } + + // t.Principal (bucket.Principal) (map) + case "principal": + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("reading object open for field t.Principal: %w", err) + } + + t.Principal = map[string]commands.Unit{} + + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("peeking object close for field t.Principal: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("reading object close for field t.Principal: %w", err) + } + } else { + for i, l := 0, 8192; i < l; i++ { + var k string + { + sval, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("reading string for field k: string too long") + } + return fmt.Errorf("reading string for field k: %w", err) + } + k = string(sval) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("reading object colon for field t.Principal: %w", err) + } + var v commands.Unit + + if err := v.UnmarshalDagJSON(jr); err != nil { + return fmt.Errorf("unmarshaling v: %w", err) + } + + t.Principal[k] = v + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("reading object close or comma for field t.Principal: %w", err) + } + if close { + break + } + } + } + + // t.Resources (bucket.StringSet) (map) + case "resource": + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("reading object open for field t.Resources: %w", err) + } + + t.Resources = map[string]commands.Unit{} + + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("peeking object close for field t.Resources: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("reading object close for field t.Resources: %w", err) + } + } else { + for i, l := 0, 8192; i < l; i++ { + var k string + { + sval, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("reading string for field k: string too long") + } + return fmt.Errorf("reading string for field k: %w", err) + } + k = string(sval) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("reading object colon for field t.Resources: %w", err) + } + var v commands.Unit + + if err := v.UnmarshalDagJSON(jr); err != nil { + return fmt.Errorf("unmarshaling v: %w", err) + } + + t.Resources[k] = v + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("reading object close or comma for field t.Resources: %w", err) + } + if close { + break + } + } + } + + // t.Sid (string) (string) + case "sid": + { + sval, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("reading string for field t.Sid: string too long") + } + return fmt.Errorf("reading string for field t.Sid: %w", err) + } + t.Sid = string(sval) + } + default: + // Field doesn't exist on this type, so ignore it + if err := jr.DiscardType(); err != nil { + return fmt.Errorf("ignoring field %s for Statement: %w", name, err) + } + } + + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("reading object close or comma for field Statement: %w", err) + } + if close { + break + } + if i == 8192-1 { + return fmt.Errorf("map too large for Statement") + } + } + } + + return nil +} func (t *InfoArguments) MarshalDagJSON(w io.Writer) error { jw := jsg.NewDagJsonWriter(w) if t == nil { diff --git a/commands/s3/bucket/policy.go b/commands/s3/bucket/policy.go new file mode 100644 index 0000000..df888bd --- /dev/null +++ b/commands/s3/bucket/policy.go @@ -0,0 +1,13 @@ +//go:build !codegen + +package bucket + +import ( + "github.com/fil-forge/ucantone/binding" + "github.com/fil-forge/ucantone/ucan/command" +) + +// Policy is the `/s3/bucket/policy` command. Ingot invokes it on Hilt with a +// forwarded S3 GetBucketPolicy, PutBucketPolicy or DeleteBucketPolicy request; +// the request's method selects the operation. +var Policy = binding.Bind[*PolicyArguments, *PolicyOK](command.MustParse("/s3/bucket/policy")) diff --git a/commands/s3/bucket/policy_codec.go b/commands/s3/bucket/policy_codec.go new file mode 100644 index 0000000..1d05cc4 --- /dev/null +++ b/commands/s3/bucket/policy_codec.go @@ -0,0 +1,224 @@ +//go:build !codegen + +// The codecs below call generated encoders, which the generator builds the +// package without. + +package bucket + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "slices" + "strings" + + jsg "github.com/alanshaw/dag-json-gen" + "github.com/fil-forge/libforge/commands/internal/codec" + cbg "github.com/whyrusleeping/cbor-gen" +) + +// canonical returns the statements deduplicated and ordered by +// [StatementCID]: the order every encoding writes. The result is never nil. +func (s StatementSet) canonical() []Statement { + type keyed struct { + key string + st Statement + } + keyed_ := make([]keyed, 0, len(s)) + for _, st := range s { + keyed_ = append(keyed_, keyed{key: StatementCID(st).String(), st: st}) + } + slices.SortFunc(keyed_, func(a, b keyed) int { return strings.Compare(a.key, b.key) }) + keyed_ = slices.CompactFunc(keyed_, func(a, b keyed) bool { return a.key == b.key }) + out := make([]Statement, len(keyed_)) + for i, k := range keyed_ { + out[i] = k.st + } + return out +} + +// MarshalJSON encodes the set as a list in canonical order; nil encodes as +// an empty list. +func (s StatementSet) MarshalJSON() ([]byte, error) { return json.Marshal(s.canonical()) } + +// UnmarshalJSON decodes a list of statements, keeping the order given. +func (s *StatementSet) UnmarshalJSON(data []byte) error { + var list []Statement + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&list); err != nil { + return err + } + *s = list + return nil +} + +// MarshalCBOR writes the set as an array in canonical order. +func (s StatementSet) MarshalCBOR(w io.Writer) error { + cw := cbg.NewCborWriter(w) + c := s.canonical() + if err := codec.WriteCborArrayHeader(cw, len(c)); err != nil { + return err + } + for i := range c { + if err := c[i].MarshalCBOR(cw); err != nil { + return err + } + } + return nil +} + +// UnmarshalCBOR reads an array of statements, at most [MaxSetSize]. +func (s *StatementSet) UnmarshalCBOR(r io.Reader) error { + cr := cbg.NewCborReader(r) + n, err := codec.ReadCborArrayHeader(cr) + if err != nil { + return err + } + if n > MaxSetSize { + return fmt.Errorf("statement set holds %d statements, at most %d are allowed", n, MaxSetSize) + } + out := make(StatementSet, n) + for i := range out { + if err := out[i].UnmarshalCBOR(cr); err != nil { + return err + } + } + *s = out + return nil +} + +// MarshalDagJSON writes the set as an array in canonical order. +func (s StatementSet) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if err := jw.WriteArrayOpen(); err != nil { + return err + } + for i, st := range s.canonical() { + if i > 0 { + if err := jw.WriteComma(); err != nil { + return err + } + } + if err := st.MarshalDagJSON(jw); err != nil { + return err + } + } + return jw.WriteArrayClose() +} + +// UnmarshalDagJSON reads an array of statements, at most [MaxSetSize]. +func (s *StatementSet) UnmarshalDagJSON(r io.Reader) error { + jr := jsg.NewDagJsonReader(r) + list, err := codec.ReadJSONArray(jr, func() (Statement, error) { + var st Statement + err := st.UnmarshalDagJSON(jr) + return st, err + }) + if err != nil { + return err + } + if len(list) > MaxSetSize { + return fmt.Errorf("statement set holds %d statements, at most %d are allowed", len(list), MaxSetSize) + } + *s = list + return nil +} + +// The document's own codecs are hand-written because the generators inline a +// slice field by its kind and would bypass StatementSet's canonical order. +// They write the layout the generators would: CBOR keys shortest first, +// DAG-JSON keys sorted. + +// MarshalCBOR writes d as a map of version and statement. +func (d *PolicyDocument) MarshalCBOR(w io.Writer) error { + cw := cbg.NewCborWriter(w) + if d == nil { + _, err := cw.Write(cbg.CborNull) + return err + } + if err := codec.WriteCborMapHeader(cw, 2); err != nil { + return err + } + if err := codec.WriteCborString(cw, "version"); err != nil { + return err + } + if err := codec.WriteCborString(cw, d.Version); err != nil { + return err + } + if err := codec.WriteCborString(cw, "statement"); err != nil { + return err + } + return d.Statements.MarshalCBOR(cw) +} + +// UnmarshalCBOR reads a map of version and statement. +func (d *PolicyDocument) UnmarshalCBOR(r io.Reader) error { + *d = PolicyDocument{} + cr := cbg.NewCborReader(r) + n, err := codec.ReadCborMapHeader(cr) + if err != nil { + return err + } + for i := uint64(0); i < n; i++ { + key, err := codec.ReadCborString(cr) + if err != nil { + return err + } + switch key { + case "version": + d.Version, err = codec.ReadCborString(cr) + case "statement": + err = d.Statements.UnmarshalCBOR(cr) + default: + return fmt.Errorf("unknown field %q in a policy document", key) + } + if err != nil { + return err + } + } + return nil +} + +// MarshalDagJSON writes d as an object of statement and version. +func (d *PolicyDocument) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if d == nil { + return jw.WriteNull() + } + if err := jw.WriteObjectOpen(); err != nil { + return err + } + if err := codec.WriteJSONKey(jw, "statement", false); err != nil { + return err + } + if err := d.Statements.MarshalDagJSON(jw); err != nil { + return err + } + if err := codec.WriteJSONKey(jw, "version", true); err != nil { + return err + } + if err := jw.WriteString(d.Version); err != nil { + return err + } + return jw.WriteObjectClose() +} + +// UnmarshalDagJSON reads an object of statement and version. +func (d *PolicyDocument) UnmarshalDagJSON(r io.Reader) error { + *d = PolicyDocument{} + jr := jsg.NewDagJsonReader(r) + return codec.ReadJSONObject(jr, func(name string) error { + switch name { + case "version": + v, err := jr.ReadString(codec.MaxString) + d.Version = v + return err + case "statement": + return d.Statements.UnmarshalDagJSON(jr) + default: + return fmt.Errorf("unknown field %q in a policy document", name) + } + }) +} diff --git a/commands/s3/bucket/policy_json.go b/commands/s3/bucket/policy_json.go new file mode 100644 index 0000000..ad6267f --- /dev/null +++ b/commands/s3/bucket/policy_json.go @@ -0,0 +1,408 @@ +package bucket + +import ( + "bytes" + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/fil-forge/libforge/commands" + "github.com/fil-forge/ucantone/errors" +) + +// Version2012 and Version2008 are the policy language versions a document +// may declare. Forge evaluates both the same way. +const ( + Version2012 = "2012-10-17" + Version2008 = "2008-10-17" +) + +// Wildcard is the principal "*", naming every principal of the tenant. It is +// carried as the bare JSON string "*" or as {"AWS": "*"}; inside a list it is +// rejected. +const Wildcard = "*" + +// PolicyWildcard is the action "s3:*", standing for every policy action. +const PolicyWildcard = "s3:*" + +// UserARNPrefix is the prefix of a principal's user ARN; the principal id +// follows it. +const UserARNPrefix = "arn:aws:iam:::user/" + +// s3ARNPrefix and objectsSuffix make up the policy bucket's ARNs, the only +// resources a document names. +const ( + s3ARNPrefix = "arn:aws:s3:::" + objectsSuffix = "/*" +) + +// MaxSetSize bounds the statements of a policy and the principals of a +// statement. A real policy is far smaller. +const MaxSetSize = 4096 + +// policyActions is the allowlist of permissions a bucket policy statement may +// carry, sorted for [PolicyAction]'s binary search. The rest of the S3 +// permission vocabulary stays out on purpose: a key holding s3:CreateBucket +// or s3:DeleteBucket acts outside the policy that granted it, a key holding a +// policy operation could rewrite the policy that granted it, and every +// principal holds s3:ListAllMyBuckets. +var policyActions = sorted([]string{ + "s3:AbortMultipartUpload", + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:GetObject", + "s3:GetObjectLegalHold", + "s3:GetObjectRetention", + "s3:GetObjectVersion", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:ListBucketVersions", + "s3:ListMultipartUploadParts", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", +}) + +func sorted(s []string) []string { + slices.Sort(s) + return s +} + +// bucketActions are the policy actions that act on the bucket rather than on +// its objects; every other policy action is an object action. The split is +// versitygw's, which evaluates the policy against these ARNs. +var bucketActions = []string{ + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:ListBucketVersions", +} + +// PolicyAction reports whether a may appear in a bucket policy statement. The +// wildcard [PolicyWildcard] is not a policy action. +func PolicyAction(a string) bool { + _, ok := slices.BinarySearch(policyActions, a) + return ok +} + +// PolicyActions returns a copy of the allowlist: every permission a bucket +// policy may grant, sorted, so that expanding [PolicyWildcard] is +// deterministic. +func PolicyActions() []string { return slices.Clone(policyActions) } + +// objectAction reports whether policy action a acts on objects. +func objectAction(a string) bool { return !slices.Contains(bucketActions, a) } + +// InvalidPolicyErrorName is the name of [ErrInvalidPolicy]. +const InvalidPolicyErrorName = "InvalidBucketPolicy" + +// ErrInvalidPolicy is returned for a policy document that is not well formed. +// The returned error wraps it with the reason. +var ErrInvalidPolicy = errors.New(InvalidPolicyErrorName, "invalid bucket policy") + +// UserARN returns the ARN a document names principal id by. +func UserARN(id string) string { return UserARNPrefix + id } + +// ParseUserARN returns the principal id a user ARN names. The id is whatever +// follows [UserARNPrefix]; which ids exist is the tenant's business. +func ParseUserARN(arn string) (id string, err error) { + id, ok := strings.CutPrefix(arn, UserARNPrefix) + if !ok { + return "", fmt.Errorf("%q is not a user ARN %q", arn, UserARN("")) + } + if id == "" { + return "", fmt.Errorf("%q has an empty principal id", arn) + } + return id, nil +} + +// BucketARN returns the ARN of bucket itself, which bucket actions need. +func BucketARN(bucket string) string { return s3ARNPrefix + bucket } + +// ObjectsARN returns the ARN of every object in bucket, which object actions +// need. +func ObjectsARN(bucket string) string { return s3ARNPrefix + bucket + objectsSuffix } + +// ParseS3ARN splits arn:aws:s3::: or arn:aws:s3:::/* into the +// bucket and whether it names the bucket's objects. Any other shape, a key +// prefix or a wildcard in the bucket name included, is an error saying why. +func ParseS3ARN(arn string) (bucket string, objects bool, err error) { + rest, ok := strings.CutPrefix(arn, s3ARNPrefix) + if !ok { + return "", false, fmt.Errorf("%q is not an S3 ARN", arn) + } + bucket, objects = strings.CutSuffix(rest, objectsSuffix) + if bucket == "" { + return "", false, fmt.Errorf("%q has an empty bucket name", arn) + } + if strings.ContainsAny(bucket, "/*?") { + return "", false, fmt.Errorf("%q: a bucket name must not hold /, * or ?", arn) + } + return bucket, objects, nil +} + +// NewStringSet returns the set of the given members. +func NewStringSet(members ...string) StringSet { + s := make(StringSet, len(members)) + for _, m := range members { + s[m] = commands.Unit{} + } + return s +} + +// Members returns the set's members, sorted, never nil. +func (s StringSet) Members() []string { + out := make([]string, 0, len(s)) + for m := range s { + out = append(out, m) + } + slices.Sort(out) + return out +} + +// MarshalJSON encodes the set as the sorted list of its members; nil encodes +// as an empty list. +func (s StringSet) MarshalJSON() ([]byte, error) { return json.Marshal(s.Members()) } + +// UnmarshalJSON accepts a string or a list of strings, as AWS allows for +// Action and Resource, holding at most [MaxSetSize] entries; duplicates +// collapse. Anything else is an error wrapping [ErrInvalidPolicy]. +func (s *StringSet) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + var members []string + if len(data) > 0 && data[0] == '"' { + var one string + if err := json.Unmarshal(data, &one); err != nil { + return fmt.Errorf("%v: %w", err, ErrInvalidPolicy) + } + members = []string{one} + } else if err := json.Unmarshal(data, &members); err != nil { + return fmt.Errorf("must be a string or a list of strings: %w", ErrInvalidPolicy) + } + if len(members) > MaxSetSize { + return fmt.Errorf("holds %d entries, at most %d are allowed: %w", len(members), MaxSetSize, ErrInvalidPolicy) + } + *s = NewStringSet(members...) + return nil +} + +// Everyone returns the principal set naming every principal of the tenant. +func Everyone() Principal { return Principal{Wildcard: {}} } + +// Only returns the principal set naming exactly the given ids. +func Only(ids ...string) Principal { return Principal(NewStringSet(ids...)) } + +// All reports whether p names every principal of the tenant: it holds +// [Wildcard]. +func (p Principal) All() bool { + _, ok := p[Wildcard] + return ok +} + +// Members returns p's members, sorted, never nil. +func (p Principal) Members() []string { return StringSet(p).Members() } + +// MarshalJSON encodes a set holding [Wildcard] as "*" and anything else as +// {"AWS": [user ARNs]}, sorted, nil encoding as an empty list. +func (p Principal) MarshalJSON() ([]byte, error) { + if p.All() { + return json.Marshal(Wildcard) + } + ids := p.Members() + arns := make([]string, len(ids)) + for i, id := range ids { + arns[i] = UserARN(id) + } + return json.Marshal(struct { + AWS []string `json:"AWS"` + }{arns}) +} + +// UnmarshalJSON accepts "*", {"AWS": "*"}, {"AWS": ""} or +// {"AWS": ["", ...]}. Anything else is an error wrapping +// [ErrInvalidPolicy]. +func (p *Principal) UnmarshalJSON(data []byte) error { + const shape = `must be "*" or {"AWS": user ARNs}` + data = bytes.TrimSpace(data) + if len(data) > 0 && data[0] == '"' { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("Principal: %v: %w", err, ErrInvalidPolicy) + } + if s != Wildcard { + return fmt.Errorf("Principal %q: a string principal must be %q: %w", s, Wildcard, ErrInvalidPolicy) + } + *p = Everyone() + return nil + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(data, &obj); err != nil || obj == nil { + return fmt.Errorf("Principal: %s: %w", shape, ErrInvalidPolicy) + } + for k := range obj { + if k != "AWS" { + return fmt.Errorf("Principal: unknown key %q, %s: %w", k, shape, ErrInvalidPolicy) + } + } + aws, ok := obj["AWS"] + if !ok { + return fmt.Errorf("Principal: %s: %w", shape, ErrInvalidPolicy) + } + var one string + if json.Unmarshal(aws, &one) == nil && one == Wildcard { + *p = Everyone() + return nil + } + var arns StringSet + if err := json.Unmarshal(aws, &arns); err != nil { + return fmt.Errorf("Principal: %s: %w", shape, ErrInvalidPolicy) + } + ids := make(Principal, len(arns)) + for _, arn := range arns.Members() { + if arn == Wildcard { + return fmt.Errorf("Principal %q must be the bare string or {\"AWS\": %q}, not a list entry: %w", Wildcard, Wildcard, ErrInvalidPolicy) + } + id, err := ParseUserARN(arn) + if err != nil { + return fmt.Errorf("Principal must be a user ARN: %v: %w", err, ErrInvalidPolicy) + } + ids[id] = commands.Unit{} + } + *p = ids + return nil +} + +// UnmarshalJSON parses a policy document strictly and refuses one that is not +// well formed, so an invalid policy never comes into existence: a field the +// schema does not define, a property name that differs from the schema in +// case, a malformed principal, or a document [WellFormed] rejects is an error +// wrapping [ErrInvalidPolicy]. Action and Resource may each be a string or a +// list; the lists are sets, so order and duplicates do not survive. +func (p *PolicyDocument) UnmarshalJSON(data []byte) error { + if err := strictKeys(data); err != nil { + return err + } + type plain PolicyDocument // the struct without this method + var d plain + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&d); err != nil { + if errors.Is(err, ErrInvalidPolicy) { + return fmt.Errorf("decoding policy: %w", err) + } + return fmt.Errorf("decoding policy: %v: %w", err, ErrInvalidPolicy) + } + if err := WellFormed(PolicyDocument(d)); err != nil { + return err + } + *p = PolicyDocument(d) + return nil +} + +// strictKeys rejects any property name that is not spelled exactly as the +// schema defines it, and a null Sid. encoding/json matches field names +// case-insensitively, so DisallowUnknownFields alone would accept "statement", +// "effect" or "ACTION". +// A document this cannot parse is left to the decoder, which reports it. +func strictKeys(data []byte) error { + var top map[string]json.RawMessage + if err := json.Unmarshal(data, &top); err != nil { + return nil + } + for k := range top { + if k != "Version" && k != "Statement" { + return fmt.Errorf("decoding policy: unknown field %q: %w", k, ErrInvalidPolicy) + } + } + var statements []map[string]json.RawMessage + if err := json.Unmarshal(top["Statement"], &statements); err != nil { + return nil + } + for _, st := range statements { + for k, v := range st { + switch k { + case "Sid": + // encoding/json decodes null into "" without error, which would + // read as an absent Sid. + if string(bytes.TrimSpace(v)) == "null" { + return fmt.Errorf("decoding policy: Sid must be a string: %w", ErrInvalidPolicy) + } + case "Effect", "Principal", "Action", "Resource": + default: + return fmt.Errorf("decoding policy: unknown field %q: %w", k, ErrInvalidPolicy) + } + } + } + return nil +} + +// WellFormed is the structural check every decoded document passes: a +// recognized Version, at least one statement and at most [MaxSetSize], each +// with a recognized effect, naming [Wildcard] or at least one principal and +// at most MaxSetSize (and never Wildcard inside the list), holding at least +// one action, each a [PolicyAction] or [PolicyWildcard], and at least one +// resource, each an ARN of the one bucket every resource in the document +// names, covering what its actions act on: an object action needs the objects +// ARN in its statement, a bucket action the bucket ARN, and "s3:*" both. It +// returns an error wrapping [ErrInvalidPolicy] otherwise. +func WellFormed(d PolicyDocument) error { + if d.Version != Version2012 && d.Version != Version2008 { + return fmt.Errorf("policy Version %q must be %q or %q: %w", d.Version, Version2012, Version2008, ErrInvalidPolicy) + } + if len(d.Statements) == 0 { + return fmt.Errorf("Statement must not be empty: %w", ErrInvalidPolicy) + } + if len(d.Statements) > MaxSetSize { + return fmt.Errorf("Statement holds %d statements, at most %d are allowed: %w", len(d.Statements), MaxSetSize, ErrInvalidPolicy) + } + bucket := "" + for i, st := range d.Statements { + if st.Effect != Allow && st.Effect != Deny { + return fmt.Errorf("statement %d: Effect %q must be %q or %q: %w", i, st.Effect, Allow, Deny, ErrInvalidPolicy) + } + switch { + case st.Principal.All() && len(st.Principal) > 1: + return fmt.Errorf("statement %d: Principal %q must be the bare string, not a list entry: %w", i, Wildcard, ErrInvalidPolicy) + case len(st.Principal) == 0: + return fmt.Errorf("statement %d: Principal must not be empty: %w", i, ErrInvalidPolicy) + case len(st.Principal) > MaxSetSize: + return fmt.Errorf("statement %d: Principal names %d principals, at most %d are allowed: %w", i, len(st.Principal), MaxSetSize, ErrInvalidPolicy) + } + if len(st.Actions) == 0 { + return fmt.Errorf("statement %d: Action must not be empty: %w", i, ErrInvalidPolicy) + } + for _, a := range st.Actions.Members() { + if a != PolicyWildcard && !PolicyAction(a) { + return fmt.Errorf("statement %d: Action %q is not a policy action: %w", i, a, ErrInvalidPolicy) + } + } + if len(st.Resources) == 0 { + return fmt.Errorf("statement %d: Resource must not be empty: %w", i, ErrInvalidPolicy) + } + var hasBucket, hasObjects bool + for _, r := range st.Resources.Members() { + b, objects, err := ParseS3ARN(r) + if err != nil { + return fmt.Errorf("statement %d: Resource %q must be %q or %q: %v: %w", i, r, BucketARN(""), ObjectsARN(""), err, ErrInvalidPolicy) + } + if bucket == "" { + bucket = b + } + if b != bucket { + return fmt.Errorf("statement %d: Resource %q names bucket %q, the policy names %q: %w", i, r, b, bucket, ErrInvalidPolicy) + } + hasBucket = hasBucket || !objects + hasObjects = hasObjects || objects + } + for _, a := range st.Actions.Members() { + wildcard := a == PolicyWildcard + if (wildcard || objectAction(a)) && !hasObjects { + return fmt.Errorf("statement %d: Action %q needs Resource %q: %w", i, a, ObjectsARN(bucket), ErrInvalidPolicy) + } + if (wildcard || !objectAction(a)) && !hasBucket { + return fmt.Errorf("statement %d: Action %q needs Resource %q: %w", i, a, BucketARN(bucket), ErrInvalidPolicy) + } + } + } + return nil +} diff --git a/commands/s3/bucket/policy_test.go b/commands/s3/bucket/policy_test.go new file mode 100644 index 0000000..8a71a61 --- /dev/null +++ b/commands/s3/bucket/policy_test.go @@ -0,0 +1,311 @@ +package bucket_test + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/fil-forge/libforge/commands/s3/bucket" + "github.com/stretchr/testify/require" +) + +func TestPolicyOKRoundTrip(t *testing.T) { + in := &bucket.PolicyOK{ + Policy: &bucket.PolicyDocument{ + Version: "2012-10-17", + Statements: []bucket.Statement{ + { + Sid: "readers", + Effect: bucket.Allow, + Principal: bucket.Only("alice", "bob"), + Actions: bucket.NewStringSet("s3:GetObject"), + Resources: bucket.NewStringSet("arn:aws:s3:::b/*"), + }, + { + Effect: bucket.Deny, + Principal: bucket.Everyone(), + Actions: bucket.NewStringSet("s3:DeleteObject"), + Resources: bucket.NewStringSet("arn:aws:s3:::b/*"), + }, + }, + }, + } + + // The wire carries the statements in canonical order, so a round trip + // keeps the set, not the order the fixture lists it in. + var cb bytes.Buffer + require.NoError(t, in.MarshalCBOR(&cb)) + var out bucket.PolicyOK + require.NoError(t, out.UnmarshalCBOR(bytes.NewReader(cb.Bytes()))) + require.Equal(t, in.Policy.Version, out.Policy.Version) + require.ElementsMatch(t, in.Policy.Statements, out.Policy.Statements) + + var jb bytes.Buffer + require.NoError(t, in.MarshalDagJSON(&jb)) + var outJSON bucket.PolicyOK + require.NoError(t, outJSON.UnmarshalDagJSON(bytes.NewReader(jb.Bytes()))) + require.Equal(t, out, outJSON) + + aws, err := json.Marshal(out.Policy) + require.NoError(t, err) + // Statements come out in CID order: the deny's sorts before the readers'. + require.JSONEq(t, `{"Version":"2012-10-17","Statement":[`+ + `{"Effect":"Deny","Principal":"*","Action":["s3:DeleteObject"],"Resource":["arn:aws:s3:::b/*"]},`+ + `{"Sid":"readers","Effect":"Allow","Principal":{"AWS":["arn:aws:iam:::user/alice","arn:aws:iam:::user/bob"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::b/*"]}]}`, string(aws)) +} + +func TestPolicyOKWithoutPolicy(t *testing.T) { + in := &bucket.PolicyOK{} + var cb bytes.Buffer + require.NoError(t, in.MarshalCBOR(&cb)) + var out bucket.PolicyOK + require.NoError(t, out.UnmarshalCBOR(bytes.NewReader(cb.Bytes()))) + require.Nil(t, out.Policy) +} + +func TestPrincipalJSON(t *testing.T) { + for _, tc := range []struct { + in string + want bucket.Principal + }{ + {`"*"`, bucket.Everyone()}, + {`{"AWS":"*"}`, bucket.Everyone()}, + {`{"AWS":"arn:aws:iam:::user/alice"}`, bucket.Only("alice")}, + {`{"AWS":["arn:aws:iam:::user/alice","arn:aws:iam:::user/alice"]}`, bucket.Only("alice")}, + } { + var p bucket.Principal + require.NoError(t, json.Unmarshal([]byte(tc.in), &p), tc.in) + require.Equal(t, tc.want, p, tc.in) + } + for _, bad := range []string{`"alice"`, `{"AWS":["*"]}`, `{"AWS":"alice"}`, `{"Service":"x"}`, `{}`, `null`} { + var p bucket.Principal + require.Error(t, json.Unmarshal([]byte(bad), &p), bad) + } + + t.Run("an id round-trips byte for byte, nothing is escaped", func(t *testing.T) { + in := bucket.Only(`a/b:c"d`) + raw, err := json.Marshal(in) + require.NoError(t, err) + var back bucket.Principal + require.NoError(t, json.Unmarshal(raw, &back)) + require.Equal(t, in, back) + }) +} + +func TestParseS3ARN(t *testing.T) { + b, objects, err := bucket.ParseS3ARN("arn:aws:s3:::photos") + require.NoError(t, err) + require.Equal(t, "photos", b) + require.False(t, objects) + + b, objects, err = bucket.ParseS3ARN("arn:aws:s3:::photos/*") + require.NoError(t, err) + require.Equal(t, "photos", b) + require.True(t, objects) + + for _, tc := range []struct{ arn, want string }{ + {"photos", "not an S3 ARN"}, + {"arn:aws:iam:::user/alice", "not an S3 ARN"}, + {"arn:aws:s3:::", "empty bucket name"}, + {"arn:aws:s3:::/*", "empty bucket name"}, + {"arn:aws:s3:::photos/2024/*", "bucket name must not hold"}, + {"arn:aws:s3:::photos/cat.jpg", "bucket name must not hold"}, + {"arn:aws:s3:::*/*", "bucket name must not hold"}, + {"arn:aws:s3:::ph?tos", "bucket name must not hold"}, + } { + _, _, err := bucket.ParseS3ARN(tc.arn) + require.ErrorContains(t, err, tc.want, tc.arn) + } +} + +func TestParseUserARN(t *testing.T) { + id, err := bucket.ParseUserARN(bucket.UserARN("alice")) + require.NoError(t, err) + require.Equal(t, "alice", id) + + for _, tc := range []struct{ arn, want string }{ + {"alice", "not a user ARN"}, + {"arn:aws:iam::123456789012:user/alice", "not a user ARN"}, + {"arn:aws:iam:::role/alice", "not a user ARN"}, + {"arn:aws:iam:::user/", "empty principal id"}, + } { + _, err := bucket.ParseUserARN(tc.arn) + require.ErrorContains(t, err, tc.want, tc.arn) + } +} + +// policyJSON wraps statements, given as JSON, in a 2012-10-17 document. +func policyJSON(statements ...string) string { + return `{"Version": "2012-10-17", "Statement": [` + strings.Join(statements, ",") + `]}` +} + +// res is the Resource member covering every policy action on photos. +const res = `"Resource": ["arn:aws:s3:::photos", "arn:aws:s3:::photos/*"]` + +func TestBucketPolicyUnmarshalJSON(t *testing.T) { + t.Run("decodes the documented shape, keeping its order", func(t *testing.T) { + var d bucket.PolicyDocument + require.NoError(t, json.Unmarshal([]byte(policyJSON( + `{"Sid": "owners", "Effect": "Allow", "Principal": {"AWS": ["arn:aws:iam:::user/bob", "arn:aws:iam:::user/alice"]}, "Action": ["s3:ListBucket", "s3:GetObject"], `+res+`}`, + `{"Effect": "Deny", "Principal": "*", "Action": "s3:PutObjectRetention", "Resource": "arn:aws:s3:::photos/*"}`, + )), &d)) + require.Equal(t, bucket.PolicyDocument{Version: bucket.Version2012, Statements: []bucket.Statement{ + {Sid: "owners", Effect: bucket.Allow, Principal: bucket.Only("bob", "alice"), Actions: bucket.NewStringSet("s3:ListBucket", "s3:GetObject"), Resources: bucket.NewStringSet(bucket.BucketARN("photos"), bucket.ObjectsARN("photos"))}, + {Effect: bucket.Deny, Principal: bucket.Everyone(), Actions: bucket.NewStringSet("s3:PutObjectRetention"), Resources: bucket.NewStringSet(bucket.ObjectsARN("photos"))}, + }}, d) + }) + + rejects := []struct { + name string + body string + want string + }{ + {"a missing version", `{"Statement": [{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], ` + res + `}]}`, `Version "" must be`}, + {"an unknown version", `{"Version": "2024-01-01", "Statement": [{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], ` + res + `}]}`, `Version "2024-01-01" must be`}, + {"unknown top-level field", `{"Version": "2012-10-17", "Statement": [], "Id": "x"}`, `unknown field "Id"`}, + {"a condition", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], ` + res + `, "Condition": {}}`), `unknown field "Condition"`}, + {"a not-principal", policyJSON(`{"Effect": "Deny", "NotPrincipal": "*", "Action": ["s3:GetObject"], ` + res + `}`), `unknown field "NotPrincipal"`}, + {"old field names", `{"statements": [{"effect": "allow", "principals": ["alice"], "actions": ["s3:GetObject"]}]}`, `unknown field "statements"`}, + {"a string principal other than the wildcard", policyJSON(`{"Effect": "Allow", "Principal": "alice", "Action": ["s3:GetObject"], ` + res + `}`), `a string principal must be "*"`}, + {"a bare id list principal", policyJSON(`{"Effect": "Allow", "Principal": ["alice"], "Action": ["s3:GetObject"], ` + res + `}`), `must be "*" or {"AWS": user ARNs}`}, + {"an ARN with an account", policyJSON(`{"Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::123456789012:user/alice"}, "Action": ["s3:GetObject"], ` + res + `}`), `must be a user ARN`}, + {"a bare id inside AWS", policyJSON(`{"Effect": "Allow", "Principal": {"AWS": ["alice"]}, "Action": ["s3:GetObject"], ` + res + `}`), `must be a user ARN`}, + {"a user ARN without an id", policyJSON(`{"Effect": "Allow", "Principal": {"AWS": "arn:aws:iam:::user/"}, "Action": ["s3:GetObject"], ` + res + `}`), `must be a user ARN`}, + {"a role ARN", policyJSON(`{"Effect": "Allow", "Principal": {"AWS": "arn:aws:iam:::role/alice"}, "Action": ["s3:GetObject"], ` + res + `}`), `must be a user ARN`}, + {"a principal key other than AWS", policyJSON(`{"Effect": "Allow", "Principal": {"Service": "s3.amazonaws.com"}, "Action": ["s3:GetObject"], ` + res + `}`), `unknown key "Service"`}, + {"a lowercase AWS key", policyJSON(`{"Effect": "Allow", "Principal": {"aws": "*"}, "Action": ["s3:GetObject"], ` + res + `}`), `unknown key "aws"`}, + {"AWS beside another key", policyJSON(`{"Effect": "Allow", "Principal": {"AWS": "*", "Federated": "x"}, "Action": ["s3:GetObject"], ` + res + `}`), `unknown key "Federated"`}, + {"an empty principal object", policyJSON(`{"Effect": "Allow", "Principal": {}, "Action": ["s3:GetObject"], ` + res + `}`), `must be "*" or {"AWS": user ARNs}`}, + {"the wildcard inside the AWS list", policyJSON(`{"Effect": "Allow", "Principal": {"AWS": ["arn:aws:iam:::user/alice", "*"]}, "Action": ["s3:GetObject"], ` + res + `}`), `not a list entry`}, + {"a lowercase top-level field", `{"Version": "2012-10-17", "statement": []}`, `unknown field "statement"`}, + {"a lowercase version field", `{"version": "2012-10-17", "Statement": []}`, `unknown field "version"`}, + {"a lowercase statement field", policyJSON(`{"effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], ` + res + `}`), `unknown field "effect"`}, + {"an upper-case statement field", policyJSON(`{"Effect": "Allow", "Principal": "*", "ACTION": ["s3:GetObject"], ` + res + `}`), `unknown field "ACTION"`}, + {"a lowercase resource field", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], "resource": "arn:aws:s3:::photos/*"}`), `unknown field "resource"`}, + {"a null sid", policyJSON(`{"Sid": null, "Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], ` + res + `}`), `Sid must be a string`}, + {"an action that is neither string nor list", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": 7, ` + res + `}`), `must be a string or a list of strings`}, + // The structure is checked while parsing, so an invalid policy never + // comes into existence. + {"empty statements", `{"Version": "2012-10-17", "Statement": []}`, `Statement must not be empty`}, + {"an unknown effect", policyJSON(`{"Effect": "Permit", "Principal": "*", "Action": ["s3:GetObject"], ` + res + `}`), `Effect "Permit"`}, + {"a lowercase effect", policyJSON(`{"Effect": "allow", "Principal": "*", "Action": ["s3:GetObject"], ` + res + `}`), `Effect "allow"`}, + {"an empty principal list", policyJSON(`{"Effect": "Allow", "Principal": {"AWS": []}, "Action": ["s3:GetObject"], ` + res + `}`), `Principal must not be empty`}, + {"a missing principal", policyJSON(`{"Effect": "Allow", "Action": ["s3:GetObject"], ` + res + `}`), `Principal must not be empty`}, + {"empty actions", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": [], ` + res + `}`), `Action must not be empty`}, + {"a bucket-level action", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["s3:CreateBucket"], ` + res + `}`), `Action "s3:CreateBucket"`}, + {"an unknown action", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["s3:Frobnicate"], ` + res + `}`), `Action "s3:Frobnicate"`}, + {"a bare star action", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["*"], ` + res + `}`), `Action "*"`}, + {"an invalid action in a later statement", policyJSON( + `{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], `+res+`}`, + `{"Effect": "Deny", "Principal": {"AWS": "arn:aws:iam:::user/alice"}, "Action": ["s3:ListAllMyBuckets"], `+res+`}`, + ), `statement 1: Action "s3:ListAllMyBuckets"`}, + {"a missing resource", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"]}`), `Resource must not be empty`}, + {"an empty resource list", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], "Resource": []}`), `Resource must not be empty`}, + {"a resource that is not an S3 ARN", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], "Resource": "photos"}`), `Resource "photos" must be`}, + {"a key-prefix resource", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], "Resource": "arn:aws:s3:::photos/2024/*"}`), `Resource "arn:aws:s3:::photos/2024/*" must be`}, + {"a single-object resource", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], "Resource": "arn:aws:s3:::photos/cat.jpg"}`), `must be`}, + {"a wildcard bucket", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], "Resource": "arn:aws:s3:::*/*"}`), `must be`}, + {"an empty bucket", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::"}`), `must be`}, + {"resources naming two buckets", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::photos/*", "arn:aws:s3:::videos/*"]}`), `names bucket "videos", the policy names "photos"`}, + {"statements naming two buckets", policyJSON( + `{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], "Resource": "arn:aws:s3:::photos/*"}`, + `{"Effect": "Allow", "Principal": "*", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::videos"}`, + ), `statement 1: Resource "arn:aws:s3:::videos" names bucket "videos"`}, + {"an object action on the bucket ARN", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], "Resource": "arn:aws:s3:::photos"}`), `Action "s3:GetObject" needs Resource "arn:aws:s3:::photos/*"`}, + {"a bucket action on the objects ARN", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::photos/*"}`), `Action "s3:ListBucket" needs Resource "arn:aws:s3:::photos"`}, + {"the action wildcard on the objects ARN alone", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": "s3:*", "Resource": "arn:aws:s3:::photos/*"}`), `Action "s3:*" needs Resource "arn:aws:s3:::photos"`}, + {"the action wildcard on the bucket ARN alone", policyJSON(`{"Effect": "Allow", "Principal": "*", "Action": "s3:*", "Resource": "arn:aws:s3:::photos"}`), `Action "s3:*" needs Resource "arn:aws:s3:::photos/*"`}, + } + for _, tt := range rejects { + t.Run("rejects "+tt.name, func(t *testing.T) { + var d bucket.PolicyDocument + err := json.Unmarshal([]byte(tt.body), &d) + require.ErrorIs(t, err, bucket.ErrInvalidPolicy) + require.ErrorContains(t, err, tt.want) + }) + } +} + +func TestPolicyActions(t *testing.T) { + actions := bucket.PolicyActions() + require.IsIncreasing(t, actions) + for _, a := range actions { + require.True(t, bucket.PolicyAction(a), a) + } + for _, a := range []string{bucket.PolicyWildcard, "*", "s3:CreateBucket", "s3:DeleteBucket", "s3:ListAllMyBuckets", "s3:PutBucketPolicy"} { + require.False(t, bucket.PolicyAction(a), a) + } +} + +func TestStatementIPLD(t *testing.T) { + st := bucket.Statement{ + Effect: bucket.Allow, + Principal: bucket.Only("bob", "alice", "bob"), + Actions: bucket.NewStringSet("s3:PutObject", "s3:GetObject", "s3:GetObject"), + Resources: bucket.NewStringSet("arn:aws:s3:::b/*", "arn:aws:s3:::b"), + } + + t.Run("DAG-JSON is the sets form with sorted keys", func(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, st.MarshalDagJSON(&buf)) + require.Equal(t, `{"action":{"s3:GetObject":{},"s3:PutObject":{}},"effect":"Allow","principal":{"alice":{},"bob":{}},"resource":{"arn:aws:s3:::b":{},"arn:aws:s3:::b/*":{}}}`, buf.String()) + + withSid := st + withSid.Sid = "rw" + buf.Reset() + require.NoError(t, withSid.MarshalDagJSON(&buf)) + require.Contains(t, buf.String(), `,"sid":"rw"}`) + }) + + t.Run("the wildcard principal is the single member *", func(t *testing.T) { + all := st + all.Principal = bucket.Everyone() + var buf bytes.Buffer + require.NoError(t, all.MarshalDagJSON(&buf)) + require.Contains(t, buf.String(), `"principal":{"*":{}}`) + }) + + t.Run("decoding round-trips, sets never nil", func(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, st.MarshalDagJSON(&buf)) + var back bucket.Statement + require.NoError(t, back.UnmarshalDagJSON(bytes.NewReader(buf.Bytes()))) + require.Equal(t, st, back) + + var empty bucket.Statement + require.NoError(t, empty.UnmarshalDagJSON(strings.NewReader(`{"action":{},"effect":"Deny","principal":{},"resource":{}}`))) + require.Equal(t, bucket.Statement{Effect: bucket.Deny, Principal: bucket.Principal{}, Actions: bucket.StringSet{}, Resources: bucket.StringSet{}}, empty) + }) + + t.Run("CBOR round trip", func(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, st.MarshalCBOR(&buf)) + var back bucket.Statement + require.NoError(t, back.UnmarshalCBOR(bytes.NewReader(buf.Bytes()))) + require.Equal(t, st, back) + }) +} + +func TestStringSetJSON(t *testing.T) { + t.Run("marshals as the sorted list, nil as an empty list", func(t *testing.T) { + raw, err := json.Marshal(bucket.NewStringSet("b", "a", "b")) + require.NoError(t, err) + require.Equal(t, `["a","b"]`, string(raw)) + + raw, err = json.Marshal(bucket.Statement{}) + require.NoError(t, err) + require.Equal(t, `{"Effect":"","Principal":{"AWS":[]},"Action":[],"Resource":[]}`, string(raw)) + }) + + t.Run("unmarshals a string or a list", func(t *testing.T) { + var s bucket.StringSet + require.NoError(t, json.Unmarshal([]byte(`"x"`), &s)) + require.Equal(t, bucket.NewStringSet("x"), s) + require.NoError(t, json.Unmarshal([]byte(`["y","x","y"]`), &s)) + require.Equal(t, bucket.NewStringSet("x", "y"), s) + for _, bad := range []string{`1`, `{"x":1}`, `[1]`} { + require.ErrorIs(t, json.Unmarshal([]byte(bad), &s), bucket.ErrInvalidPolicy, bad) + } + }) +} diff --git a/commands/s3/bucket/types.go b/commands/s3/bucket/types.go index 4faf6b6..dee8c25 100644 --- a/commands/s3/bucket/types.go +++ b/commands/s3/bucket/types.go @@ -1,6 +1,7 @@ package bucket import ( + "github.com/fil-forge/libforge/commands" "github.com/fil-forge/libforge/commands/s3" "github.com/fil-forge/ucantone/did" ) @@ -23,6 +24,70 @@ type ListArguments struct { Request s3.Request `cborgen:"request" dagjsongen:"request"` } +// PolicyArguments are the arguments to `/s3/bucket/policy`. +type PolicyArguments struct { + // Request is the AWS S3 GetBucketPolicy, PutBucketPolicy or + // DeleteBucketPolicy request. + Request s3.Request `cborgen:"request" dagjsongen:"request"` + // Body is the policy document a PutBucketPolicy request carries. It is + // empty for the other two operations. + Body []byte `cborgen:"body" dagjsongen:"body"` +} + +// PolicyOK is the successful result of `/s3/bucket/policy`. The policy's +// entity tag is not carried: both sides compute it from the document with +// [ETag]. +type PolicyOK struct { + // Policy is the stored policy document. It is set only for a + // GetBucketPolicy. + Policy *PolicyDocument `cborgen:"policy,omitempty" dagjsongen:"policy,omitempty"` +} + +// PolicyDocument is a bucket policy document. Its JSON encoding is the AWS +// bucket policy document; its CBOR and DAG-JSON encodings are hand-written +// (policy_codec.go) so that the statements go out in canonical order. +type PolicyDocument struct { + Version string `json:"Version"` + Statements StatementSet `json:"Statement"` +} + +// StatementSet is a policy's statements: any order in memory, canonical on +// the wire. Every encoding (JSON, DAG-JSON, CBOR) writes them deduplicated +// and ordered by [StatementCID], so two documents that differ only in +// statement order or duplicates encode the same. +type StatementSet []Statement + +// Effect is whether a [Statement] grants or withholds its actions. +type Effect string + +const ( + Allow Effect = "Allow" + Deny Effect = "Deny" +) + +// Statement grants (Allow) or withholds (Deny) actions on the bucket's +// resources to principals. Sid is an optional label the caller chooses. On +// the wire (CBOR and DAG-JSON) the three sets are maps from member to +// [commands.Unit]; in the AWS JSON document they are lists (see +// policy_json.go). +type Statement struct { + Sid string `cborgen:"sid,omitempty" dagjsongen:"sid,omitempty" json:"Sid,omitempty"` + Effect Effect `cborgen:"effect" dagjsongen:"effect" json:"Effect"` + Principal Principal `cborgen:"principal" dagjsongen:"principal" json:"Principal"` + Actions StringSet `cborgen:"action" dagjsongen:"action" json:"Action"` + Resources StringSet `cborgen:"resource" dagjsongen:"resource" json:"Resource"` +} + +// StringSet is a set of strings: a map from each member to [commands.Unit], +// which the IPLD codecs write with sorted keys. Its JSON form is the sorted +// list of its members. +type StringSet map[string]commands.Unit + +// Principal is the set of principal ids a statement applies to, or the +// single member [Wildcard] for every principal of the tenant. Its JSON +// encoding is the string "*" or {"AWS": [user ARNs]} (see policy_json.go). +type Principal StringSet + // InfoArguments are the arguments to `/s3/bucket/info`. type InfoArguments struct { // Name is the global bucket name to look up.