From 9283641cb42d18094992209c5a4ecbf6a4493a7d Mon Sep 17 00:00:00 2001 From: Evgenii Stratonikov Date: Fri, 4 Apr 2025 14:51:22 +0300 Subject: [PATCH 01/11] [#164] nns: Add test for deleteRecords() from subdomain Signed-off-by: Evgenii Stratonikov --- tests/nns_test.go | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/nns_test.go b/tests/nns_test.go index 56ed98a..66db77a 100644 --- a/tests/nns_test.go +++ b/tests/nns_test.go @@ -273,6 +273,57 @@ func TestNNSRegister(t *testing.T) { c.InvokeFail(t, "token not found", "getRecords", "testdomain.com", int64(nns.SOA)) } +func TestDeleteRecords_SubdomainNoRegister(t *testing.T) { + c := newNNSInvoker(t, true) + + refresh, retry, expire, ttl := int64(101), int64(102), int64(103), int64(104) + c.Invoke(t, true, "register", + "test.com", c.CommitteeHash, + "myemail@frostfs.info", refresh, retry, expire, ttl) + + checkRecords := func(t *testing.T, domain string, typ nns.RecordType, expected ...string) { + s, err := c.TestInvoke(t, "getRecords", domain, int64(typ)) + require.NoError(t, err) + + if len(expected) == 0 { + _, ok := s.Pop().Item().(stackitem.Null) + require.True(t, ok, "expected 0 records") + return + } + + arr, ok := s.Pop().Value().([]stackitem.Item) + require.True(t, ok, "expected an array '%s' %d", domain, typ) + + actual := make([]string, len(arr)) + for i := range actual { + b, err := arr[i].TryBytes() + require.NoError(t, err) + actual[i] = string(b) + } + + require.ElementsMatch(t, expected, actual) + } + + c.Invoke(t, stackitem.Null{}, "addRecord", "a.test.com", int64(nns.TXT), "recA1") + c.Invoke(t, stackitem.Null{}, "addRecord", "a.test.com", int64(nns.TXT), "recA2") + c.Invoke(t, stackitem.Null{}, "addRecord", "b.test.com", int64(nns.TXT), "recB") + c.Invoke(t, stackitem.Null{}, "addRecord", "test.com", int64(nns.TXT), "recTop") + + { // Delete subdomain records. + c.Invoke(t, stackitem.Null{}, "deleteRecords", "a.test.com", int64(nns.TXT)) + checkRecords(t, "test.com", nns.TXT, "recTop") + checkRecords(t, "a.test.com", nns.TXT) + checkRecords(t, "b.test.com", nns.TXT, "recB") + } + + { // Delete domain records. + c.Invoke(t, stackitem.Null{}, "deleteRecords", "test.com", int64(nns.TXT)) + checkRecords(t, "test.com", nns.TXT) + checkRecords(t, "a.test.com", nns.TXT) + checkRecords(t, "b.test.com", nns.TXT, "recB") + } +} + func TestDeleteDomain(t *testing.T) { c := newNNSInvoker(t, false) From b38d42baf3d35d997dc1d354773c68ab27ee683d Mon Sep 17 00:00:00 2001 From: Evgenii Stratonikov Date: Sat, 5 Apr 2025 09:34:55 +0300 Subject: [PATCH 02/11] [#165] nns: Ignore domain expirations Domain expirations undeniably complicate reasoning about contract behaviour: 1. SOA record expire field has a bit of a different semantics 2. For our coredns backend we would like to receive everything we put, sudden domain expirations can make life harder. 3. This expiration depends on block time, which in turn may differ from the real timestamp. Close #165. Signed-off-by: Evgenii Stratonikov --- nns/namestate.go | 13 +++------ nns/nns_contract.go | 41 +++++--------------------- rpcclient/nns/client.go | 22 -------------- tests/nns_test.go | 65 ----------------------------------------- 4 files changed, 11 insertions(+), 130 deletions(-) diff --git a/nns/namestate.go b/nns/namestate.go index 72bbf94..ea186c8 100644 --- a/nns/namestate.go +++ b/nns/namestate.go @@ -7,19 +7,14 @@ import ( // NameState represents domain name state. type NameState struct { - Owner interop.Hash160 - Name string + Owner interop.Hash160 + Name string + // Expiration field used to contain wall-clock time of a domain expiration. + // It is preserved for backwards compatibility, but is unused by the contract and should be ignored. Expiration int64 Admin interop.Hash160 } -// ensureNotExpired panics if domain name is expired. -func (n NameState) ensureNotExpired() { - if int64(runtime.GetTime()) >= n.Expiration { - panic("name has expired") - } -} - // checkAdmin panics if script container is not signed by the domain name admin. func (n NameState) checkAdmin() { if runtime.CheckWitness(n.Owner) { diff --git a/nns/nns_contract.go b/nns/nns_contract.go index d4bc83b..ce8523a 100644 --- a/nns/nns_contract.go +++ b/nns/nns_contract.go @@ -148,8 +148,7 @@ func Properties(tokenID []byte) map[string]any { ctx := storage.GetReadOnlyContext() ns := getNameState(ctx, tokenID) return map[string]any{ - "name": ns.Name, - "expiration": ns.Expiration, + "name": ns.Name, } } @@ -308,7 +307,6 @@ func extractCnametgt(ctx storage.Context, name, domain string) string { // checkParent returns parent domain or empty string if domain not found. func checkParent(ctx storage.Context, fragments []string) string { - now := int64(runtime.GetTime()) last := len(fragments) - 1 name := fragments[last] parent := "" @@ -320,10 +318,6 @@ func checkParent(ctx storage.Context, fragments []string) string { if nsBytes == nil { continue } - ns := std.Deserialize(nsBytes.([]byte)).(NameState) - if now >= ns.Expiration { - panic("domain expired: " + name) - } parent = name } return parent @@ -390,19 +384,15 @@ func register(ctx storage.Context, name string, owner interop.Hash160, email str nsBytes := storage.Get(ctx, append([]byte{prefixName}, tokenKey...)) if nsBytes != nil { ns := std.Deserialize(nsBytes.([]byte)).(NameState) - if int64(runtime.GetTime()) < ns.Expiration { - return false - } oldOwner = ns.Owner updateBalance(ctx, []byte(name), oldOwner, -1) } else { updateTotalSupply(ctx, +1) } ns := NameState{ - Owner: owner, - Name: name, - // NNS expiration is in milliseconds - Expiration: int64(runtime.GetTime() + expire*1000), + Owner: owner, + Name: name, + Expiration: 0, } checkAvailableGlobalDomain(ctx, name) @@ -415,18 +405,6 @@ func register(ctx storage.Context, name string, owner interop.Hash160, email str return true } -// Renew increases domain expiration date. -func Renew(name string) int64 { - checkDomainNameLength(name) - runtime.BurnGas(GetPrice()) - ctx := storage.GetContext() - ns := getNameState(ctx, []byte(name)) - ns.checkAdmin() - ns.Expiration += millisecondsInYear - putNameState(ctx, ns) - return ns.Expiration -} - // UpdateSOA updates soa record. func UpdateSOA(name, email string, refresh, retry, expire, ttl int) { checkDomainNameLength(name) @@ -731,9 +709,7 @@ func getNameStateWithKey(ctx storage.Context, tokenKey []byte) NameState { if nsBytes == nil { panic("token not found") } - ns := std.Deserialize(nsBytes.([]byte)).(NameState) - ns.ensureNotExpired() - return ns + return std.Deserialize(nsBytes.([]byte)).(NameState) } // putNameState stores domain name state. @@ -801,7 +777,7 @@ func addRecord(ctx storage.Context, tokenId []byte, name string, typ RecordType, ns := NameState{ Name: globalDomain, Owner: nsOriginal.Owner, - Expiration: nsOriginal.Expiration, + Expiration: 0, Admin: nsOriginal.Admin, } @@ -1125,10 +1101,7 @@ func tokenIDFromName(name string) string { nameKey := append([]byte{prefixName}, tokenKey...) nsBytes := storage.Get(ctx, nameKey) if nsBytes != nil { - ns := std.Deserialize(nsBytes.([]byte)).(NameState) - if int64(runtime.GetTime()) < ns.Expiration { - return name[sum:] - } + return name[sum:] } sum += len(fragments[i]) + 1 } diff --git a/rpcclient/nns/client.go b/rpcclient/nns/client.go index adfc41e..43c0d0a 100644 --- a/rpcclient/nns/client.go +++ b/rpcclient/nns/client.go @@ -286,28 +286,6 @@ func (c *Contract) RegisterUnsigned(name string, owner util.Uint160, email strin return c.actor.MakeUnsignedRun(script, nil) } -// Renew creates a transaction invoking `renew` method of the contract. -// This transaction is signed and immediately sent to the network. -// The values returned are its hash, ValidUntilBlock value and error if any. -func (c *Contract) Renew(name string) (util.Uint256, uint32, error) { - return c.actor.SendCall(c.hash, "renew", name) -} - -// RenewTransaction creates a transaction invoking `renew` method of the contract. -// This transaction is signed, but not sent to the network, instead it's -// returned to the caller. -func (c *Contract) RenewTransaction(name string) (*transaction.Transaction, error) { - return c.actor.MakeCall(c.hash, "renew", name) -} - -// RenewUnsigned creates a transaction invoking `renew` method of the contract. -// This transaction is not signed, it's simply returned to the caller. -// Any fields of it that do not affect fees can be changed (ValidUntilBlock, -// Nonce), fee values (NetworkFee, SystemFee) can be increased as well. -func (c *Contract) RenewUnsigned(name string) (*transaction.Transaction, error) { - return c.actor.MakeUnsignedCall(c.hash, "renew", nil, name) -} - // SetAdmin creates a transaction invoking `setAdmin` method of the contract. // This transaction is signed and immediately sent to the network. // The values returned are its hash, ValidUntilBlock value and error if any. diff --git a/tests/nns_test.go b/tests/nns_test.go index 66db77a..4661a9f 100644 --- a/tests/nns_test.go +++ b/tests/nns_test.go @@ -4,7 +4,6 @@ import ( "fmt" "math/big" "path" - "strings" "testing" "time" @@ -551,45 +550,6 @@ func TestNNSGetAllRecords(t *testing.T) { require.False(t, iter.Next()) } -func TestExpiration(t *testing.T) { - c := newNNSInvoker(t, true) - - refresh, retry, expire, ttl := int64(101), int64(102), int64(msPerYear/1000*10), int64(104) - c.Invoke(t, true, "register", - "testdomain.com", c.CommitteeHash, - "myemail@frostfs.info", refresh, retry, expire, ttl) - - checkProperties := func(t *testing.T, expiration uint64) { - expected := stackitem.NewMapWithValue([]stackitem.MapElement{ - {Key: stackitem.Make("name"), Value: stackitem.Make("testdomain.com")}, - {Key: stackitem.Make("expiration"), Value: stackitem.Make(expiration)}, - }) - s, err := c.TestInvoke(t, "properties", "testdomain.com") - require.NoError(t, err) - require.Equal(t, expected.Value(), s.Top().Item().Value()) - } - - top := c.TopBlock(t) - expiration := top.Timestamp + uint64(expire*1000) - checkProperties(t, expiration) - - b := c.NewUnsignedBlock(t) - b.Timestamp = expiration - 2 // test invoke is done with +1 timestamp - require.NoError(t, c.Chain.AddBlock(c.SignBlock(b))) - checkProperties(t, expiration) - - b = c.NewUnsignedBlock(t) - b.Timestamp = expiration - 1 - require.NoError(t, c.Chain.AddBlock(c.SignBlock(b))) - - _, err := c.TestInvoke(t, "properties", "testdomain.com") - require.Error(t, err) - require.True(t, strings.Contains(err.Error(), "name has expired")) - - c.InvokeFail(t, "name has expired", "getAllRecords", "testdomain.com") - c.InvokeFail(t, "name has expired", "ownerOf", "testdomain.com") -} - func TestNNSSetAdmin(t *testing.T) { c := newNNSInvoker(t, true) @@ -691,31 +651,6 @@ func TestNNSIsAvailable(t *testing.T) { c.InvokeFail(t, "domain name too long", "isAvailable", getTooLongDomainName(255)) } -func TestNNSRenew(t *testing.T) { - c := newNNSInvoker(t, true) - - acc := c.NewAccount(t) - c1 := c.WithSigners(c.Committee, acc) - refresh, retry, expire, ttl := int64(101), int64(102), int64(103), int64(104) - c1.Invoke(t, true, "register", - "testdomain.com", c.CommitteeHash, - "myemail@frostfs.info", refresh, retry, expire, ttl) - - const msPerYear = 365 * 24 * time.Hour / time.Millisecond - b := c.TopBlock(t) - ts := b.Timestamp + uint64(expire*1000) + uint64(msPerYear) - - cAcc := c.WithSigners(acc) - cAcc.InvokeFail(t, "not witnessed by admin", "renew", "testdomain.com") - c1.Invoke(t, ts, "renew", "testdomain.com") - expected := stackitem.NewMapWithValue([]stackitem.MapElement{ - {Key: stackitem.Make("name"), Value: stackitem.Make("testdomain.com")}, - {Key: stackitem.Make("expiration"), Value: stackitem.Make(ts)}, - }) - cAcc.Invoke(t, expected, "properties", "testdomain.com") - c.InvokeFail(t, "domain name too long", "renew", getTooLongDomainName(255)) -} - func TestNNSResolve(t *testing.T) { c := newNNSInvoker(t, true) From c350b7372f9b9432fb134dffb60e7d86524735eb Mon Sep 17 00:00:00 2001 From: Evgenii Stratonikov Date: Sat, 5 Apr 2025 17:33:20 +0300 Subject: [PATCH 03/11] [#167] nns: Fix addRecord() for domains without SOA record It works for all but second-level domains. Signed-off-by: Evgenii Stratonikov --- nns/nns_contract.go | 2 +- tests/nns_test.go | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/nns/nns_contract.go b/nns/nns_contract.go index ce8523a..9185e45 100644 --- a/nns/nns_contract.go +++ b/nns/nns_contract.go @@ -1095,7 +1095,7 @@ func tokenIDFromName(name string) string { ctx := storage.GetReadOnlyContext() sum := 0 - l := len(fragments) - 1 + l := len(fragments) for i := 0; i < l; i++ { tokenKey := getTokenKey([]byte(name[sum:])) nameKey := append([]byte{prefixName}, tokenKey...) diff --git a/tests/nns_test.go b/tests/nns_test.go index 4661a9f..24f5761 100644 --- a/tests/nns_test.go +++ b/tests/nns_test.go @@ -269,7 +269,7 @@ func TestNNSRegister(t *testing.T) { expected = stackitem.NewArray([]stackitem.Item{stackitem.NewByteArray([]byte("testdomain.com"))}) c.CheckTxNotificationEvent(t, tx, 4, state.NotificationEvent{ScriptHash: c.Hash, Name: "DeleteDomain", Item: expected}) - c.InvokeFail(t, "token not found", "getRecords", "testdomain.com", int64(nns.SOA)) + c.Invoke(t, stackitem.Null{}, "getRecords", "testdomain.com", int64(nns.SOA)) } func TestDeleteRecords_SubdomainNoRegister(t *testing.T) { @@ -437,6 +437,10 @@ func TestTLDRecord(t *testing.T) { result := []stackitem.Item{stackitem.NewByteArray([]byte("1.2.3.4"))} c.Invoke(t, result, "resolve", "com", int64(nns.A)) + + t.Run("subdomain", func(t *testing.T) { + c.Invoke(t, stackitem.Null{}, "addRecord", "a.com", int64(nns.TXT), "test=frostfs") + }) } func TestNNSRegisterMulti(t *testing.T) { From 6674526a5befab7d1329f91f367d997e131436de Mon Sep 17 00:00:00 2001 From: Nikita Zinkevich Date: Tue, 1 Apr 2025 09:50:29 +0300 Subject: [PATCH 04/11] [#159] frostfsid: Restrict creating entities for non-active namespace Signed-off-by: Nikita Zinkevich --- frostfsid/frostfsid_contract.go | 18 ++++++ tests/frostfsid_test.go | 105 +++++++++++++++++++++++++++++++- 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/frostfsid/frostfsid_contract.go b/frostfsid/frostfsid_contract.go index 0f4dc92..12f411f 100644 --- a/frostfsid/frostfsid_contract.go +++ b/frostfsid/frostfsid_contract.go @@ -101,6 +101,8 @@ const ( addressPrefix = 'A' nsActiveState = "active" + nsFrozenState = "frozen" + nsPurgeState = "purge" ) func _deploy(data any, isUpdate bool) { @@ -209,6 +211,7 @@ func Version() int { // CreateSubject creates a new subject in the specified namespace with the provided public key. func CreateSubject(ns string, key interop.PublicKey) { ctx := storage.GetContext() + checkNamespaceState(ns) checkContractOwner(ctx) if len(key) != interop.PublicKeyCompressedLen { @@ -284,6 +287,8 @@ func AddSubjectKey(addr interop.Hash160, key interop.PublicKey) { panic("address not found") } subject := std.Deserialize(data).(Subject) + checkNamespaceState(subject.Namespace) + subject.AdditionalKeys = append(subject.AdditionalKeys, key) storage.Put(ctx, sKey, std.Serialize(subject)) @@ -347,6 +352,7 @@ func SetSubjectName(addr interop.Hash160, name string) { } subject := std.Deserialize(data).(Subject) + checkNamespaceState(subject.Namespace) oldName := subject.Name subject.Name = name storage.Put(ctx, sKey, std.Serialize(subject)) @@ -372,6 +378,7 @@ func SetSubjectKV(addr interop.Hash160, key, val string) { } subject := std.Deserialize(data).(Subject) + checkNamespaceState(subject.Namespace) if subject.KV == nil { subject.KV = map[string]string{} } @@ -668,6 +675,7 @@ func ListNamespaceSubjects(ns string) iterator.Iterator { // CreateGroup creates a new group within the specified namespace. func CreateGroup(ns, group string) int { ctx := storage.GetContext() + checkNamespaceState(ns) checkContractOwner(ctx) if group == "" { @@ -781,6 +789,7 @@ func SetGroupName(ns string, groupID int, name string) { } gr := std.Deserialize(data).(Group) + checkNamespaceState(gr.Namespace) oldName := gr.Name gr.Name = name storage.Put(ctx, gKey, std.Serialize(gr)) @@ -802,6 +811,7 @@ func SetGroupKV(ns string, groupID int, key, val string) { } gr := std.Deserialize(data).(Group) + checkNamespaceState(gr.Namespace) if gr.KV == nil { gr.KV = map[string]string{} } @@ -849,6 +859,7 @@ func AddSubjectToGroup(addr interop.Hash160, groupID int) { panic("subject not found") } subject := std.Deserialize(data).(Subject) + checkNamespaceState(subject.Namespace) gKey := groupKey(subject.Namespace, groupID) data = storage.Get(ctx, gKey).([]byte) @@ -1149,3 +1160,10 @@ func migrateNamespacesState(ctx storage.Context) { storage.Put(ctx, string(kv.Key), namespaceData) } } + +func checkNamespaceState(name string) { + ns := GetNamespace(name) + if ns.State == nsFrozenState || ns.State == nsPurgeState { + panic("namespace is non-active") + } +} diff --git a/tests/frostfsid_test.go b/tests/frostfsid_test.go index bb9fc17..bab8916 100644 --- a/tests/frostfsid_test.go +++ b/tests/frostfsid_test.go @@ -24,7 +24,10 @@ import ( const frostfsidPath = "../frostfsid" -const defaultNamespace = "" +const ( + defaultNamespace = "" + customNamespace = "custom" +) const ( setAdminMethod = "setAdmin" @@ -66,6 +69,12 @@ const ( nsActiveState = "active" ) +const ( + frozenState = "frozen" + purgeState = "purge" + namespaceNonActive = "namespace is non-active" +) + const notWitnessedError = "not witnessed" type testFrostFSIDInvoker struct { @@ -306,6 +315,56 @@ func TestFrostFSID_SubjectManagement(t *testing.T) { require.ElementsMatch(t, addresses, []util.Uint160{subjKeyAddr, newSubjKey.PublicKey().GetScriptHash()}) }) + t.Run("subject operations for non-active namespace", func(t *testing.T) { + invoker.Invoke(t, stackitem.Null{}, createNamespaceMethod, customNamespace) + + baseKey, err := keys.NewPrivateKey() + require.NoError(t, err) + baseAddr, baseBytes := baseKey.PublicKey().GetScriptHash(), baseKey.PublicKey().Bytes() + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, frozenState) + + invoker.InvokeFail(t, namespaceNonActive, createSubjectMethod, customNamespace, baseBytes) + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, purgeState) + invoker.InvokeFail(t, namespaceNonActive, createSubjectMethod, customNamespace, baseBytes) + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, "active") + invoker.Invoke(t, stackitem.Null{}, createSubjectMethod, customNamespace, baseBytes) + + t.Run("addSubjectKey", func(t *testing.T) { + newSubjKey, err := keys.NewPrivateKey() + require.NoError(t, err) + keyBytes := newSubjKey.PublicKey().Bytes() + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, frozenState) + invoker.InvokeFail(t, namespaceNonActive, addSubjectKeyMethod, baseAddr, keyBytes) + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, purgeState) + invoker.InvokeFail(t, namespaceNonActive, addSubjectKeyMethod, baseAddr, keyBytes) + }) + + t.Run("setSubjectKV", func(t *testing.T) { + const key, val = "key", "val" + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, frozenState) + invoker.InvokeFail(t, namespaceNonActive, setSubjectKVMethod, baseAddr, key, val) + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, purgeState) + invoker.InvokeFail(t, namespaceNonActive, setSubjectKVMethod, baseAddr, key, val) + }) + + t.Run("setSubjectName", func(t *testing.T) { + const login = "testlogin" + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, frozenState) + invoker.InvokeFail(t, namespaceNonActive, setSubjectNameMethod, baseAddr, login) + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, purgeState) + invoker.InvokeFail(t, namespaceNonActive, setSubjectNameMethod, baseAddr, login) + }) + }) + anonInvoker.InvokeFail(t, notWitnessedError, deleteSubjectMethod, subjKeyAddr) invoker.Invoke(t, stackitem.Null{}, deleteSubjectMethod, subjKeyAddr) @@ -635,6 +694,50 @@ func TestFrostFSID_GroupManagement(t *testing.T) { groups := parseGroups(t, readIteratorAll(s)) require.Empty(t, groups) }) + + t.Run("operations with non-active namespace", func(t *testing.T) { + invoker.Invoke(t, stackitem.Null{}, createNamespaceMethod, customNamespace) + + customGroupID := int64(2) + customGroupName := "customGroup" + invoker.Invoke(t, stackitem.Make(customGroupID), createGroupMethod, customNamespace, customGroupName) + + subjKey, err := keys.NewPrivateKey() + require.NoError(t, err) + invoker.Invoke(t, stackitem.Null{}, createSubjectMethod, customNamespace, subjKey.PublicKey().Bytes()) + + t.Run("createGroup", func(t *testing.T) { + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, frozenState) + invoker.InvokeFail(t, namespaceNonActive, createGroupMethod, customNamespace, customGroupName) + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, purgeState) + invoker.InvokeFail(t, namespaceNonActive, createGroupMethod, customNamespace, customGroupName) + }) + + t.Run("addSubjectToGroup", func(t *testing.T) { + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, frozenState) + invoker.InvokeFail(t, namespaceNonActive, addSubjectToGroupMethod, subjKey.PublicKey().GetScriptHash(), customGroupID) + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, purgeState) + invoker.InvokeFail(t, namespaceNonActive, addSubjectToGroupMethod, subjKey.PublicKey().GetScriptHash(), customGroupID) + }) + + t.Run("setGroupKV", func(t *testing.T) { + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, frozenState) + invoker.InvokeFail(t, namespaceNonActive, setGroupKVMethod, customNamespace, customGroupID, client.IAMARNKey, "arn") + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, purgeState) + invoker.InvokeFail(t, namespaceNonActive, setGroupKVMethod, customNamespace, customGroupID, client.IAMARNKey, "arn") + }) + + t.Run("setGroupName", func(t *testing.T) { + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, frozenState) + invoker.InvokeFail(t, namespaceNonActive, setGroupNameMethod, customNamespace, customGroupID, "newCustomGroup") + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, customNamespace, purgeState) + invoker.InvokeFail(t, namespaceNonActive, setGroupNameMethod, customNamespace, customGroupID, "newCustomGroup") + }) + }) } func TestAdditionalKeyFromPrimarySubject(t *testing.T) { From a005dc1161a563a176744dd0fa6f75c9a22d9ea1 Mon Sep 17 00:00:00 2001 From: Nikita Zinkevich Date: Mon, 31 Mar 2025 10:02:58 +0300 Subject: [PATCH 05/11] [#155] Restrict creating policies for non-active namespace Signed-off-by: Nikita Zinkevich --- common/address.go | 10 +++ policy/policy_contract.go | 53 +++++++++++- tests/policy_test.go | 176 +++++++++++++++++++++++++------------- 3 files changed, 178 insertions(+), 61 deletions(-) create mode 100644 common/address.go diff --git a/common/address.go b/common/address.go new file mode 100644 index 0000000..a5a713a --- /dev/null +++ b/common/address.go @@ -0,0 +1,10 @@ +package common + +import "github.com/nspcc-dev/neo-go/pkg/interop" + +const ( + NEO3PrefixLen = 1 + ChecksumLen = 4 + + AddressLen = NEO3PrefixLen + interop.Hash160Len + ChecksumLen +) diff --git a/policy/policy_contract.go b/policy/policy_contract.go index 8959859..10f8cd7 100644 --- a/policy/policy_contract.go +++ b/policy/policy_contract.go @@ -2,9 +2,13 @@ package policy import ( "git.frostfs.info/TrueCloudLab/frostfs-contract/common" + "git.frostfs.info/TrueCloudLab/frostfs-contract/frostfsid" + "git.frostfs.info/TrueCloudLab/frostfs-contract/nns" "github.com/nspcc-dev/neo-go/pkg/interop" + "github.com/nspcc-dev/neo-go/pkg/interop/contract" "github.com/nspcc-dev/neo-go/pkg/interop/iterator" "github.com/nspcc-dev/neo-go/pkg/interop/native/management" + "github.com/nspcc-dev/neo-go/pkg/interop/native/std" "github.com/nspcc-dev/neo-go/pkg/interop/runtime" "github.com/nspcc-dev/neo-go/pkg/interop/storage" ) @@ -36,6 +40,11 @@ const ( ErrNotAuthorized = "none of the signers is authorized to change the contract" ) +const ( + purgeNsState = "purge" + frozenNsState = "frozen" +) + // _deploy function sets up initial list of inner ring public keys. func _deploy(data any, isUpdate bool) { if isUpdate { @@ -50,7 +59,7 @@ func _deploy(data any, isUpdate bool) { ctx := storage.GetContext() if args.Admin != nil { if len(args.Admin) != 20 { - panic("invaliad admin hash length") + panic("invalid admin hash length") } storage.Put(ctx, []byte{ownerKeyPrefix}, args.Admin) } @@ -142,9 +151,51 @@ func mapToNumericCreateIfNotExists(ctx storage.Context, kind Kind, name []byte) return numericID.(int) } +func checkChainNamespace(entity Kind, name string) { + if entity != Namespace { + return + } + frostfsidAddr := getContractHash(nns.FrostfsIDNNSName) + if frostfsidAddr == nil || management.GetContract(frostfsidAddr) == nil { + panic("could not get frostfsid contract") + } + ns := contract.Call(frostfsidAddr, "getNamespace", contract.ReadOnly, name).(frostfsid.Namespace) + if ns.State == purgeNsState || ns.State == frozenNsState { + panic("namespace is non-active") + } +} + +// getContractHash returns nil when it can't resolve contract name, +// so custom error message can be thrown. +func getContractHash(name string) interop.Hash160 { + nnsContract := management.GetContractByID(1) + records := contract.Call(nnsContract.Hash, "getRecords", contract.ReadOnly, name, nns.TXT).([]string) + for _, record := range records { + contractHash := readContractHashFromNNSRecord(record) + if contractHash != nil { + return contractHash + } + } + return nil +} + +func readContractHashFromNNSRecord(nnsResponse string) interop.Hash160 { + // 40 is size of hex encoded contract hash as string + if len(nnsResponse) == 40 { + return nil + } + + decoded := std.Base58Decode([]byte(nnsResponse)) + if len(decoded) != common.AddressLen || management.GetContract(decoded[1:21]) == nil { + return nil + } + return decoded[1:21] +} + func AddChain(entity Kind, entityName string, name []byte, chain []byte) { ctx := storage.GetContext() checkAuthorization(ctx) + checkChainNamespace(entity, entityName) entityNameBytes := mapToNumericCreateIfNotExists(ctx, entity, []byte(entityName)) key := storageKey(entity, entityNameBytes, name) diff --git a/tests/policy_test.go b/tests/policy_test.go index 1355c5a..361d435 100644 --- a/tests/policy_test.go +++ b/tests/policy_test.go @@ -5,35 +5,76 @@ import ( "path" "testing" + "git.frostfs.info/TrueCloudLab/frostfs-contract/nns" "git.frostfs.info/TrueCloudLab/frostfs-contract/policy" "github.com/nspcc-dev/neo-go/pkg/core/interop/storage" + "github.com/nspcc-dev/neo-go/pkg/encoding/address" "github.com/nspcc-dev/neo-go/pkg/neotest" - "github.com/nspcc-dev/neo-go/pkg/util" "github.com/nspcc-dev/neo-go/pkg/vm" "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" + "github.com/nspcc-dev/neo-go/pkg/wallet" "github.com/stretchr/testify/require" ) const policyPath = "../policy" -func deployPolicyContract(t *testing.T, e *neotest.Executor) util.Uint160 { +type policyContracts struct { + policy *neotest.ContractInvoker + frostfsid *neotest.ContractInvoker +} + +func newPolicyInvokers(t *testing.T) *policyContracts { + e := newExecutor(t) + + n := deployNNSContract(t, e) + ffid := deployFrostfsid(t, e) + polic := deployPolicyContract(t, e) + + n.Invoke(t, true, "register", + nns.FrostfsIDNNSName, n.CommitteeHash, + "myemail@frostfs.info", defaultRefresh, defaultRetry, defaultExpire, defaultTTL) + n.Invoke(t, stackitem.Null{}, "addRecord", + nns.FrostfsIDNNSName, int64(nns.TXT), ffid.Hash.StringLE()) + n.Invoke(t, stackitem.Null{}, "addRecord", + nns.FrostfsIDNNSName, int64(nns.TXT), address.Uint160ToString(ffid.Hash)) + + return &policyContracts{ + policy: polic, + frostfsid: ffid, + } +} + +func deployNNSContract(t *testing.T, e *neotest.Executor) *neotest.ContractInvoker { + ctrNNS := neotest.CompileFile(t, e.CommitteeHash, nnsPath, path.Join(nnsPath, "config.yml")) + e.DeployContract(t, ctrNNS, nil) + + n := e.CommitteeInvoker(ctrNNS.Hash) + + return n +} + +func deployFrostfsid(t *testing.T, e *neotest.Executor) *neotest.ContractInvoker { + acc, err := wallet.NewAccount() + require.NoError(t, err) + args := make([]any, 5) + args[0] = acc.ScriptHash() + frostfsID := neotest.CompileFile(t, e.CommitteeHash, frostfsidPath, path.Join(frostfsidPath, "config.yml")) + e.DeployContract(t, frostfsID, args) + return e.CommitteeInvoker(frostfsID.Hash) +} + +func deployPolicyContract(t *testing.T, e *neotest.Executor) *neotest.ContractInvoker { cfgPath := path.Join(policyPath, "config.yml") c := neotest.CompileFile(t, e.CommitteeHash, policyPath, cfgPath) e.DeployContract(t, c, []any{nil}) - return c.Hash -} - -func newPolicyInvoker(t *testing.T) *neotest.ContractInvoker { - e := newExecutor(t) - h := deployPolicyContract(t, e) - return e.CommitteeInvoker(h) + return e.CommitteeInvoker(c.Hash) } func TestPolicy(t *testing.T) { - e := newPolicyInvoker(t) + c := newPolicyInvokers(t) - checkChainsIteratorByPrefix(t, e, policy.Namespace, "mynamespace", "ingress", [][]byte{}) - checkChainsIteratorByPrefix(t, e, policy.Container, "cnr1", "ingress", [][]byte{}) + checkChainsIteratorByPrefix(t, c.policy, policy.Namespace, "mynamespace", "ingress", [][]byte{}) + checkChainsIteratorByPrefix(t, c.policy, policy.Container, "cnr1", "ingress", [][]byte{}) // Policies are opaque to the contract and are just raw bytes to store. p1 := []byte("chain1") @@ -41,82 +82,97 @@ func TestPolicy(t *testing.T) { p3 := []byte("chain3") p33 := []byte("chain33") - e.Invoke(t, stackitem.Null{}, "addChain", policy.Namespace, "mynamespace", "ingress:123", p1) - checkTargets(t, e, policy.Namespace, [][]byte{[]byte("mynamespace")}) - checkChains(t, e, "mynamespace", "", "ingress", [][]byte{p1}) - checkChains(t, e, "mynamespace", "", "all", nil) + c.frostfsid.Invoke(t, stackitem.Null{}, "createNamespace", "mynamespace") - e.Invoke(t, stackitem.Null{}, "addChain", policy.Container, "cnr1", "ingress:myrule2", p2) - checkTargets(t, e, policy.Namespace, [][]byte{[]byte("mynamespace")}) - checkTargets(t, e, policy.Container, [][]byte{[]byte("cnr1")}) - checkChains(t, e, "mynamespace", "", "ingress", [][]byte{p1}) // Only namespace chains. - checkChains(t, e, "mynamespace", "cnr1", "ingress", [][]byte{p1, p2}) - checkChains(t, e, "mynamespace", "cnr1", "all", nil) // No chains attached to 'all'. - checkChains(t, e, "mynamespace", "cnr2", "ingress", [][]byte{p1}) // Only namespace, no chains for the container. + c.policy.Invoke(t, stackitem.Null{}, "addChain", policy.Namespace, "mynamespace", "ingress:123", p1) + checkTargets(t, c.policy, policy.Namespace, [][]byte{[]byte("mynamespace")}) + checkChains(t, c.policy, "mynamespace", "", "ingress", [][]byte{p1}) + checkChains(t, c.policy, "mynamespace", "", "all", nil) - e.Invoke(t, stackitem.Null{}, "addChain", policy.Container, "cnr1", "ingress:myrule3", p3) - checkTargets(t, e, policy.Namespace, [][]byte{[]byte("mynamespace")}) - checkTargets(t, e, policy.Container, [][]byte{[]byte("cnr1")}) - checkChains(t, e, "mynamespace", "cnr1", "ingress", [][]byte{p1, p2, p3}) + c.policy.Invoke(t, stackitem.Null{}, "addChain", policy.Container, "cnr1", "ingress:myrule2", p2) + checkTargets(t, c.policy, policy.Namespace, [][]byte{[]byte("mynamespace")}) + checkTargets(t, c.policy, policy.Container, [][]byte{[]byte("cnr1")}) + checkChains(t, c.policy, "mynamespace", "", "ingress", [][]byte{p1}) // Only namespace chains. + checkChains(t, c.policy, "mynamespace", "cnr1", "ingress", [][]byte{p1, p2}) + checkChains(t, c.policy, "mynamespace", "cnr1", "all", nil) // No chains attached to 'all'. + checkChains(t, c.policy, "mynamespace", "cnr2", "ingress", [][]byte{p1}) // Only namespace, no chains for the container. - e.Invoke(t, stackitem.Null{}, "addChain", policy.Container, "cnr1", "ingress:myrule3", p33) - checkTargets(t, e, policy.Namespace, [][]byte{[]byte("mynamespace")}) - checkTargets(t, e, policy.Container, [][]byte{[]byte("cnr1")}) - checkChain(t, e, policy.Container, "cnr1", "ingress:myrule3", p33) - checkChains(t, e, "mynamespace", "cnr1", "ingress", [][]byte{p1, p2, p33}) // Override chain. - checkChainsByPrefix(t, e, policy.Container, "cnr1", "", [][]byte{p2, p33}) - checkChainsByPrefix(t, e, policy.IAM, "", "", nil) - checkChainKeys(t, e, policy.Container, "cnr1", []string{"ingress:myrule2", "ingress:myrule3"}) + c.policy.Invoke(t, stackitem.Null{}, "addChain", policy.Container, "cnr1", "ingress:myrule3", p3) + checkTargets(t, c.policy, policy.Namespace, [][]byte{[]byte("mynamespace")}) + checkTargets(t, c.policy, policy.Container, [][]byte{[]byte("cnr1")}) + checkChains(t, c.policy, "mynamespace", "cnr1", "ingress", [][]byte{p1, p2, p3}) - checkChainsIteratorByPrefix(t, e, policy.Container, "cnr1", "ingress:myrule3", [][]byte{p33}) - checkChainsIteratorByPrefix(t, e, policy.Container, "cnr1", "ingress", [][]byte{p2, p33}) + c.policy.Invoke(t, stackitem.Null{}, "addChain", policy.Container, "cnr1", "ingress:myrule3", p33) + checkTargets(t, c.policy, policy.Namespace, [][]byte{[]byte("mynamespace")}) + checkTargets(t, c.policy, policy.Container, [][]byte{[]byte("cnr1")}) + checkChain(t, c.policy, policy.Container, "cnr1", "ingress:myrule3", p33) + checkChains(t, c.policy, "mynamespace", "cnr1", "ingress", [][]byte{p1, p2, p33}) // Override chain. + checkChainsByPrefix(t, c.policy, policy.Container, "cnr1", "", [][]byte{p2, p33}) + checkChainsByPrefix(t, c.policy, policy.IAM, "", "", nil) + checkChainKeys(t, c.policy, policy.Container, "cnr1", []string{"ingress:myrule2", "ingress:myrule3"}) + + checkChainsIteratorByPrefix(t, c.policy, policy.Container, "cnr1", "ingress:myrule3", [][]byte{p33}) + checkChainsIteratorByPrefix(t, c.policy, policy.Container, "cnr1", "ingress", [][]byte{p2, p33}) t.Run("removal", func(t *testing.T) { t.Run("wrong name", func(t *testing.T) { - e.Invoke(t, stackitem.Null{}, "removeChain", policy.Namespace, "mynamespace", "ingress") - checkChains(t, e, "mynamespace", "", "ingress", [][]byte{p1}) + c.policy.Invoke(t, stackitem.Null{}, "removeChain", policy.Namespace, "mynamespace", "ingress") + checkChains(t, c.policy, "mynamespace", "", "ingress", [][]byte{p1}) }) - e.Invoke(t, stackitem.Null{}, "removeChain", policy.Namespace, "mynamespace", "ingress:123") - checkChains(t, e, "mynamespace", "", "ingress", nil) - checkChains(t, e, "mynamespace", "cnr1", "ingress", [][]byte{p2, p33}) // Container chains still exist. + c.policy.Invoke(t, stackitem.Null{}, "removeChain", policy.Namespace, "mynamespace", "ingress:123") + checkChains(t, c.policy, "mynamespace", "", "ingress", nil) + checkChains(t, c.policy, "mynamespace", "cnr1", "ingress", [][]byte{p2, p33}) // Container chains still exist. // Remove by prefix. - e.Invoke(t, stackitem.Null{}, "removeChainsByPrefix", policy.Container, "cnr1", "ingress") - checkChains(t, e, "mynamespace", "cnr1", "ingress", nil) + c.policy.Invoke(t, stackitem.Null{}, "removeChainsByPrefix", policy.Container, "cnr1", "ingress") + checkChains(t, c.policy, "mynamespace", "cnr1", "ingress", nil) // Remove by prefix. - e.Invoke(t, stackitem.Null{}, "removeChainsByPrefix", policy.Container, "cnr1", "ingress") - checkChains(t, e, "mynamespace", "cnr1", "ingress", nil) + c.policy.Invoke(t, stackitem.Null{}, "removeChainsByPrefix", policy.Container, "cnr1", "ingress") + checkChains(t, c.policy, "mynamespace", "cnr1", "ingress", nil) - checkTargets(t, e, policy.Namespace, [][]byte{}) - checkTargets(t, e, policy.Container, [][]byte{}) + checkTargets(t, c.policy, policy.Namespace, [][]byte{}) + checkTargets(t, c.policy, policy.Container, [][]byte{}) }) t.Run("add again after removal", func(t *testing.T) { - e.Invoke(t, stackitem.Null{}, "addChain", policy.Namespace, "mynamespace", "ingress:123", p1) - e.Invoke(t, stackitem.Null{}, "addChain", policy.Container, "cnr1", "ingress:myrule3", p33) + c.policy.Invoke(t, stackitem.Null{}, "addChain", policy.Namespace, "mynamespace", "ingress:123", p1) + c.policy.Invoke(t, stackitem.Null{}, "addChain", policy.Container, "cnr1", "ingress:myrule3", p33) - checkTargets(t, e, policy.Namespace, [][]byte{[]byte("mynamespace")}) - checkTargets(t, e, policy.Container, [][]byte{[]byte("cnr1")}) + checkTargets(t, c.policy, policy.Namespace, [][]byte{[]byte("mynamespace")}) + checkTargets(t, c.policy, policy.Container, [][]byte{[]byte("cnr1")}) + }) + + t.Run("add chain for non-active namespace", func(t *testing.T) { + c.frostfsid.Invoke(t, stackitem.Null{}, "createNamespace", "nsdisabled") + + c.frostfsid.Invoke(t, stackitem.Null{}, "updateNamespace", "nsdisabled", "frozen") + c.policy.InvokeFail(t, "namespace is non-active", "addChain", policy.Namespace, "nsdisabled", "ingress:3", p1) + + c.frostfsid.Invoke(t, stackitem.Null{}, "updateNamespace", "nsdisabled", "purge") + c.policy.InvokeFail(t, "namespace is non-active", "addChain", policy.Namespace, "nsdisabled", "ingress:3", p1) + + c.frostfsid.Invoke(t, stackitem.Null{}, "updateNamespace", "nsdisabled", "active") + c.policy.Invoke(t, stackitem.Null{}, "addChain", policy.Namespace, "nsdisabled", "ingress:3", p1) }) } func TestAutorization(t *testing.T) { - e := newPolicyInvoker(t) + c := newPolicyInvokers(t) - e.Invoke(t, stackitem.Null{}, "getAdmin") + c.policy.Invoke(t, stackitem.Null{}, "getAdmin") - s := e.NewAccount(t, 1_0000_0000) - c := e.WithSigners(s) + s := c.policy.NewAccount(t, 1_0000_0000) + cs := c.policy.WithSigners(s) args := []any{policy.Container, "cnr1", "ingress:myrule3", []byte("opaque")} - c.InvokeFail(t, policy.ErrNotAuthorized, "addChain", args...) + cs.InvokeFail(t, policy.ErrNotAuthorized, "addChain", args...) - e.Invoke(t, stackitem.Null{}, "setAdmin", s.ScriptHash()) - e.Invoke(t, stackitem.NewBuffer(s.ScriptHash().BytesBE()), "getAdmin") + c.policy.Invoke(t, stackitem.Null{}, "setAdmin", s.ScriptHash()) + c.policy.Invoke(t, stackitem.NewBuffer(s.ScriptHash().BytesBE()), "getAdmin") - c.Invoke(t, stackitem.Null{}, "addChain", args...) + cs.Invoke(t, stackitem.Null{}, "addChain", args...) } func checkChains(t *testing.T, e *neotest.ContractInvoker, namespace, container, name string, expected [][]byte) { From afe4eb3d76c524c9bac98c8b6a4169a35c4beafe Mon Sep 17 00:00:00 2001 From: "A.Mitropolskiy" Date: Tue, 8 Apr 2025 20:28:13 +0300 Subject: [PATCH 06/11] [#168] Idempotent namespace deletion method Signed-off-by: A.Mitropolskiy --- CHANGELOG.md | 1 + frostfsid/client/client.go | 13 +++++ frostfsid/client/utils.go | 3 +- frostfsid/config.yml | 4 ++ frostfsid/frostfsid_contract.go | 36 +++++++++++++- rpcclient/frostfsid/client.go | 88 +++++++++++++++++++++++++++++++++ tests/frostfsid_client_test.go | 73 +++++++++++++++++++++++++++ tests/frostfsid_test.go | 76 ++++++++++++++++++++++++++-- 8 files changed, 285 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c053d0..5f9b995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Changelog for FrostFS Contract ### Added - Field `state` to a namespace to indicate its' lifecycle stage (#154). - Method `UpdateNamespace` to adjust namespace state (#154). +- Method `DeleteNamespace` to remove existing namespace (#168). ### Changed ### Removed diff --git a/frostfsid/client/client.go b/frostfsid/client/client.go index 57f5298..8f95b00 100644 --- a/frostfsid/client/client.go +++ b/frostfsid/client/client.go @@ -114,6 +114,7 @@ const ( createNamespaceMethod = "createNamespace" updateNamespaceMethod = "updateNamespace" + deleteNamespaceMethod = "deleteNamespace" getNamespaceMethod = "getNamespace" getNamespaceExtendedMethod = "getNamespaceExtended" listNamespacesMethod = "listNamespaces" @@ -463,6 +464,18 @@ func (c Client) UpdateNamespaceCall(namespace string, state string) (method stri return updateNamespaceMethod, []any{namespace, state} } +// DeleteNamespace idempotently removes the namespace. +// Must be invoked by contract owner. +func (c Client) DeleteNamespace(namespace string) (tx util.Uint256, vub uint32, err error) { + method, args := c.DeleteNamespaceCall(namespace) + return c.act.SendCall(c.contract, method, args...) +} + +// DeleteNamespaceCall provides args for DeleteNamespace to use in commonclient.Transaction. +func (c Client) DeleteNamespaceCall(namespace string) (method string, args []any) { + return deleteNamespaceMethod, []any{namespace} +} + // ListNamespaces gets all namespaces. func (c Client) ListNamespaces() ([]*Namespace, error) { items, err := commonclient.ReadIteratorItems(c.act, iteratorBatchSize, c.contract, listNamespacesMethod) diff --git a/frostfsid/client/utils.go b/frostfsid/client/utils.go index e850efc..4dc0c70 100644 --- a/frostfsid/client/utils.go +++ b/frostfsid/client/utils.go @@ -185,7 +185,6 @@ func ParseNamespaceExtended(structArr []stackitem.Item) (*NamespaceExtended, err }, nil } -// TODO: [cleanup] (#151) rewrite this method after new release. func parseNamespace(structArr []stackitem.Item, stateIndex int) (*Namespace, error) { name, err := structArr[0].TryBytes() if err != nil { @@ -193,7 +192,7 @@ func parseNamespace(structArr []stackitem.Item, stateIndex int) (*Namespace, err } nsState := Active - if len(structArr) == stateIndex+1 { + if len(structArr) >= stateIndex+1 { nsStateBytes, err := structArr[stateIndex].TryBytes() if err != nil { return nil, err diff --git a/frostfsid/config.yml b/frostfsid/config.yml index 609665b..be3cfb3 100644 --- a/frostfsid/config.yml +++ b/frostfsid/config.yml @@ -72,6 +72,10 @@ events: type: String - name: state type: String + - name: DeleteNamespace + parameters: + - name: namespace + type: String - name: AddSubjectToNamespace parameters: - name: subjectAddress diff --git a/frostfsid/frostfsid_contract.go b/frostfsid/frostfsid_contract.go index 12f411f..d773536 100644 --- a/frostfsid/frostfsid_contract.go +++ b/frostfsid/frostfsid_contract.go @@ -156,7 +156,10 @@ func _deploy(data any, isUpdate bool) { storage.Put(ctx, groupCounterKey, maxGroupID) } - migrateNamespacesState(ctx) + if args.version < common.GetVersion(0, 21, 3) { + migrateNamespacesState(ctx) + } + return } @@ -602,6 +605,36 @@ func CreateNamespace(ns string) { runtime.Notify("CreateNamespace", ns) } +// DeleteNamespace idempotently removes a namespace with the specified name. +func DeleteNamespace(ns string) { + ctx := storage.GetContext() + checkContractOwner(ctx) + + nsKey := namespaceKey(ns) + data := storage.Get(ctx, nsKey).([]byte) + if data == nil { + return + } + + namespace := std.Deserialize(data).(Namespace) + if namespace.State != nsPurgeState { + panic("namespace should be in 'purge' state for deletion") + } + + it := storage.Find(ctx, groupPrefix(ns), storage.KeysOnly) + if iterator.Next(it) { + panic("can't delete non-empty namespace: groups still present") + } + + it = storage.Find(ctx, namespaceSubjectPrefix(ns), storage.KeysOnly) + if iterator.Next(it) { + panic("can't delete non-empty namespace: users still present") + } + + storage.Delete(ctx, nsKey) + runtime.Notify("DeleteNamespace", ns) +} + // UpdateNamespace updates existing namespace. func UpdateNamespace(ns string, state string) { ctx := storage.GetContext() @@ -1137,7 +1170,6 @@ func addressKey(address []byte) []byte { return append([]byte{addressPrefix}, address...) } -// TODO: [cleanup] (#151) remove this migration after new release. func migrateNamespacesState(ctx storage.Context) { it := storage.Find(ctx, []byte{namespaceKeysPrefix}, storage.None) diff --git a/rpcclient/frostfsid/client.go b/rpcclient/frostfsid/client.go index 19ecaf1..498b472 100644 --- a/rpcclient/frostfsid/client.go +++ b/rpcclient/frostfsid/client.go @@ -70,6 +70,11 @@ type UpdateNamespaceEvent struct { State string } +// DeleteNamespaceEvent represents "DeleteNamespace" event emitted by the contract. +type DeleteNamespaceEvent struct { + Namespace string +} + // AddSubjectToNamespaceEvent represents "AddSubjectToNamespace" event emitted by the contract. type AddSubjectToNamespaceEvent struct { SubjectAddress util.Uint160 @@ -489,6 +494,28 @@ func (c *Contract) DeleteGroupKVUnsigned(ns string, groupID *big.Int, key string return c.actor.MakeUnsignedCall(c.hash, "deleteGroupKV", nil, ns, groupID, key) } +// DeleteNamespace creates a transaction invoking `deleteNamespace` method of the contract. +// This transaction is signed and immediately sent to the network. +// The values returned are its hash, ValidUntilBlock value and error if any. +func (c *Contract) DeleteNamespace(ns string) (util.Uint256, uint32, error) { + return c.actor.SendCall(c.hash, "deleteNamespace", ns) +} + +// DeleteNamespaceTransaction creates a transaction invoking `deleteNamespace` method of the contract. +// This transaction is signed, but not sent to the network, instead it's +// returned to the caller. +func (c *Contract) DeleteNamespaceTransaction(ns string) (*transaction.Transaction, error) { + return c.actor.MakeCall(c.hash, "deleteNamespace", ns) +} + +// DeleteNamespaceUnsigned creates a transaction invoking `deleteNamespace` method of the contract. +// This transaction is not signed, it's simply returned to the caller. +// Any fields of it that do not affect fees can be changed (ValidUntilBlock, +// Nonce), fee values (NetworkFee, SystemFee) can be increased as well. +func (c *Contract) DeleteNamespaceUnsigned(ns string) (*transaction.Transaction, error) { + return c.actor.MakeUnsignedCall(c.hash, "deleteNamespace", nil, ns) +} + // DeleteSubject creates a transaction invoking `deleteSubject` method of the contract. // This transaction is signed and immediately sent to the network. // The values returned are its hash, ValidUntilBlock value and error if any. @@ -1394,6 +1421,67 @@ func (e *UpdateNamespaceEvent) FromStackItem(item *stackitem.Array) error { return nil } +// DeleteNamespaceEventsFromApplicationLog retrieves a set of all emitted events +// with "DeleteNamespace" name from the provided [result.ApplicationLog]. +func DeleteNamespaceEventsFromApplicationLog(log *result.ApplicationLog) ([]*DeleteNamespaceEvent, error) { + if log == nil { + return nil, errors.New("nil application log") + } + + var res []*DeleteNamespaceEvent + for i, ex := range log.Executions { + for j, e := range ex.Events { + if e.Name != "DeleteNamespace" { + continue + } + event := new(DeleteNamespaceEvent) + err := event.FromStackItem(e.Item) + if err != nil { + return nil, fmt.Errorf("failed to deserialize DeleteNamespaceEvent from stackitem (execution #%d, event #%d): %w", i, j, err) + } + res = append(res, event) + } + } + + return res, nil +} + +// FromStackItem converts provided [stackitem.Array] to DeleteNamespaceEvent or +// returns an error if it's not possible to do to so. +func (e *DeleteNamespaceEvent) FromStackItem(item *stackitem.Array) error { + if item == nil { + return errors.New("nil item") + } + arr, ok := item.Value().([]stackitem.Item) + if !ok { + return errors.New("not an array") + } + if len(arr) != 1 { + return errors.New("wrong number of structure elements") + } + + var ( + index = -1 + err error + ) + index++ + e.Namespace, err = func(item stackitem.Item) (string, error) { + b, err := item.TryBytes() + if err != nil { + return "", err + } + if !utf8.Valid(b) { + return "", errors.New("not a UTF-8 string") + } + return string(b), nil + }(arr[index]) + if err != nil { + return fmt.Errorf("field Namespace: %w", err) + } + + return nil +} + // AddSubjectToNamespaceEventsFromApplicationLog retrieves a set of all emitted events // with "AddSubjectToNamespace" name from the provided [result.ApplicationLog]. func AddSubjectToNamespaceEventsFromApplicationLog(log *result.ApplicationLog) ([]*AddSubjectToNamespaceEvent, error) { diff --git a/tests/frostfsid_client_test.go b/tests/frostfsid_client_test.go index 5303baf..3d97c87 100644 --- a/tests/frostfsid_client_test.go +++ b/tests/frostfsid_client_test.go @@ -189,6 +189,79 @@ func TestFrostFSID_Client_NamespaceManagement(t *testing.T) { subjects, err = ffsid.cli.ListNamespaceSubjects(namespace) require.NoError(t, err) require.Empty(t, subjects) + + namespace2 := "namespace2" + ffsid.a.await(ffsid.cli.CreateNamespace(namespace2)) + ns1, err := ffsid.cli.GetNamespace(namespace) + require.NoError(t, err) + require.Equal(t, namespace, ns1.Name) + require.Equal(t, client.Active, ns1.State) + + nsExt2, err := ffsid.cli.GetNamespaceExtended(namespace2) + require.NoError(t, err) + require.Equal(t, namespace2, nsExt2.Name) + require.Equal(t, client.Active, nsExt2.State) + + ffsid.a.await(ffsid.cli.UpdateNamespace(namespace2, client.Frozen)) + nsExt2, err = ffsid.cli.GetNamespaceExtended(namespace2) + require.NoError(t, err) + require.Equal(t, namespace2, nsExt2.Name) + require.Equal(t, client.Frozen, nsExt2.State) + + _, _, err = ffsid.cli.DeleteNamespace(namespace2) + require.ErrorContains(t, err, "namespace should be in 'purge' state for deletion") + + ffsid.a.await(ffsid.cli.UpdateNamespace(namespace2, client.Active)) + nsExt2, err = ffsid.cli.GetNamespaceExtended(namespace2) + require.NoError(t, err) + require.Equal(t, client.Active, nsExt2.State) + + subjKey2, subjAddr2 := newKey(t) + ffsid.a.await(ffsid.cli.CreateSubject(namespace2, subjKey2.PublicKey())) + + subj2, err := ffsid.cli.GetSubject(subjAddr2) + require.NoError(t, err) + require.Equal(t, namespace2, subj2.Namespace) + + ffsid.a.await(ffsid.cli.UpdateNamespace(namespace2, client.Purge)) + nsExt2, err = ffsid.cli.GetNamespaceExtended(namespace2) + require.NoError(t, err) + require.Equal(t, client.Purge, nsExt2.State) + + _, _, err = ffsid.cli.DeleteNamespace(namespace2) + require.ErrorContains(t, err, "can't delete non-empty namespace: users still present") + + ffsid.a.await(ffsid.cli.DeleteSubject(subjAddr2)) + + ffsid.a.await(ffsid.cli.UpdateNamespace(namespace2, client.Active)) + nsExt2, err = ffsid.cli.GetNamespaceExtended(namespace2) + require.NoError(t, err) + require.Equal(t, client.Active, nsExt2.State) + + groupName := "ns_group" + ffsid.a.await(ffsid.cli.CreateGroup(namespace2, groupName)) + group, err := ffsid.cli.GetGroupByName(namespace2, groupName) + require.NoError(t, err) + + ffsid.a.await(ffsid.cli.UpdateNamespace(namespace2, client.Purge)) + nsExt2, err = ffsid.cli.GetNamespaceExtended(namespace2) + require.NoError(t, err) + require.Equal(t, client.Purge, nsExt2.State) + + _, _, err = ffsid.cli.DeleteNamespace(namespace2) + require.ErrorContains(t, err, "can't delete non-empty namespace: groups still present") + + ffsid.a.await(ffsid.cli.DeleteGroup(namespace2, group.ID)) + + ffsid.a.await(ffsid.cli.DeleteNamespace(namespace2)) + _, err = ffsid.cli.GetNamespace(namespace2) + require.ErrorContains(t, err, "not found") + + namespace3 := "namespace3" + _, err = ffsid.cli.GetNamespace(namespace3) + require.ErrorContains(t, err, "not found") + _, _, err = ffsid.cli.DeleteNamespace(namespace3) + require.NoError(t, err) } func TestFrostFSID_Client_DefaultNamespace(t *testing.T) { diff --git a/tests/frostfsid_test.go b/tests/frostfsid_test.go index bab8916..3c52242 100644 --- a/tests/frostfsid_test.go +++ b/tests/frostfsid_test.go @@ -50,6 +50,7 @@ const ( getNamespaceMethod = "getNamespace" getNamespaceExtendedMethod = "getNamespaceExtended" updateNamespaceMethod = "updateNamespace" + deleteNamespaceMethod = "deleteNamespace" listNamespacesMethod = "listNamespaces" listNamespaceSubjectsMethod = "listNamespaceSubjects" @@ -70,13 +71,16 @@ const ( ) const ( - frozenState = "frozen" - purgeState = "purge" - namespaceNonActive = "namespace is non-active" + frozenState = "frozen" + purgeState = "purge" + namespaceNonActive = "namespace is non-active" + notWitnessedError = "not witnessed" + notFoundError = "namespace not found" + cantDeleteNonEmptyNamespceGroupsPresent = "can't delete non-empty namespace: groups still present" + cantDeleteNonEmptyNamespceUsersPresent = "can't delete non-empty namespace: users still present" + namespaceShouldBeInPurgeStateError = "namespace should be in 'purge' state for deletion" ) -const notWitnessedError = "not witnessed" - type testFrostFSIDInvoker struct { e *neotest.Executor contractHash util.Uint160 @@ -573,6 +577,68 @@ func TestFrostFSID_NamespaceManagement(t *testing.T) { require.Equal(t, namespace, ns.Name) require.Equal(t, "frozen", ns.State) }) + + t.Run("delete namespace", func(t *testing.T) { + namespace3 := "some-namespace3" + invoker.Invoke(t, stackitem.Null{}, createNamespaceMethod, namespace3) + + s, err = invoker.TestInvoke(t, getNamespaceMethod, namespace3) + require.NoError(t, err) + + ns := parseNamespace(t, s.Pop().Item()) + require.Equal(t, namespace3, ns.Name) + + t.Run("delete existing namespace not in a 'purge' state", func(t *testing.T) { + anonInvoker.InvokeFail(t, notWitnessedError, deleteNamespaceMethod, namespace3) + invoker.InvokeFail(t, namespaceShouldBeInPurgeStateError, deleteNamespaceMethod, namespace3) + }) + + subjKey, err := keys.NewPrivateKey() + subjKeyAddr := subjKey.PublicKey().GetScriptHash() + require.NoError(t, err) + invoker.Invoke(t, stackitem.Null{}, createSubjectMethod, ns.Name, subjKey.PublicKey().Bytes()) + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, namespace3, "purge") + require.NoError(t, err) + + t.Run("delete namespace with user fails", func(t *testing.T) { + invoker.InvokeFail(t, cantDeleteNonEmptyNamespceUsersPresent, deleteNamespaceMethod, namespace3) + }) + + invoker.Invoke(t, stackitem.Null{}, deleteSubjectMethod, subjKeyAddr) + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, namespace3, "active") + require.NoError(t, err) + + groupID1 := int64(1) + groupName1 := "group1" + invoker.Invoke(t, stackitem.Make(groupID1), createGroupMethod, ns.Name, groupName1) + + invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, namespace3, "purge") + require.NoError(t, err) + + t.Run("delete namespace with group fails", func(t *testing.T) { + invoker.InvokeFail(t, cantDeleteNonEmptyNamespceGroupsPresent, deleteNamespaceMethod, namespace3) + }) + + invoker.Invoke(t, stackitem.Null{}, deleteGroupMethod, ns.Name, groupID1) + + t.Run("delete existing namespace", func(t *testing.T) { + anonInvoker.InvokeFail(t, notWitnessedError, deleteNamespaceMethod, namespace3) + invoker.Invoke(t, stackitem.Null{}, deleteNamespaceMethod, namespace3) + require.NoError(t, err) + + invoker.InvokeFail(t, notFoundError, getNamespaceMethod, namespace3) + }) + + t.Run("delete non-existing namespace", func(t *testing.T) { + nonExistingNamespace := "non-existing-namespace" + invoker.InvokeFail(t, notFoundError, getNamespaceMethod, nonExistingNamespace) + anonInvoker.InvokeFail(t, notWitnessedError, deleteNamespaceMethod, nonExistingNamespace) + invoker.Invoke(t, stackitem.Null{}, deleteNamespaceMethod, nonExistingNamespace) + require.NoError(t, err) + }) + }) } func TestFrostFSID_GroupManagement(t *testing.T) { From 9f65415fac10d975a4a09c3e50022d82f7982fac Mon Sep 17 00:00:00 2001 From: "A.Mitropolskiy" Date: Mon, 5 May 2025 15:25:59 +0300 Subject: [PATCH 07/11] [#168] Idempotent namespace deletion method Signed-off-by: A.Mitropolskiy --- tests/frostfsid_test.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/frostfsid_test.go b/tests/frostfsid_test.go index 3c52242..f91865b 100644 --- a/tests/frostfsid_test.go +++ b/tests/frostfsid_test.go @@ -597,25 +597,19 @@ func TestFrostFSID_NamespaceManagement(t *testing.T) { subjKeyAddr := subjKey.PublicKey().GetScriptHash() require.NoError(t, err) invoker.Invoke(t, stackitem.Null{}, createSubjectMethod, ns.Name, subjKey.PublicKey().Bytes()) - invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, namespace3, "purge") - require.NoError(t, err) t.Run("delete namespace with user fails", func(t *testing.T) { invoker.InvokeFail(t, cantDeleteNonEmptyNamespceUsersPresent, deleteNamespaceMethod, namespace3) }) invoker.Invoke(t, stackitem.Null{}, deleteSubjectMethod, subjKeyAddr) - invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, namespace3, "active") - require.NoError(t, err) groupID1 := int64(1) groupName1 := "group1" invoker.Invoke(t, stackitem.Make(groupID1), createGroupMethod, ns.Name, groupName1) - invoker.Invoke(t, stackitem.Null{}, updateNamespaceMethod, namespace3, "purge") - require.NoError(t, err) t.Run("delete namespace with group fails", func(t *testing.T) { invoker.InvokeFail(t, cantDeleteNonEmptyNamespceGroupsPresent, deleteNamespaceMethod, namespace3) @@ -626,8 +620,6 @@ func TestFrostFSID_NamespaceManagement(t *testing.T) { t.Run("delete existing namespace", func(t *testing.T) { anonInvoker.InvokeFail(t, notWitnessedError, deleteNamespaceMethod, namespace3) invoker.Invoke(t, stackitem.Null{}, deleteNamespaceMethod, namespace3) - require.NoError(t, err) - invoker.InvokeFail(t, notFoundError, getNamespaceMethod, namespace3) }) @@ -636,7 +628,6 @@ func TestFrostFSID_NamespaceManagement(t *testing.T) { invoker.InvokeFail(t, notFoundError, getNamespaceMethod, nonExistingNamespace) anonInvoker.InvokeFail(t, notWitnessedError, deleteNamespaceMethod, nonExistingNamespace) invoker.Invoke(t, stackitem.Null{}, deleteNamespaceMethod, nonExistingNamespace) - require.NoError(t, err) }) }) } From 819104db74471d2f27f82ece24e005a9d4e515ba Mon Sep 17 00:00:00 2001 From: Dmitrii Stepanov Date: Mon, 5 May 2025 16:07:04 +0300 Subject: [PATCH 08/11] [#172] common: Update version to v0.21.3 Signed-off-by: Dmitrii Stepanov --- VERSION | 2 +- common/version.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index f198b15..3b8e435 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.21.2 +v0.21.3 diff --git a/common/version.go b/common/version.go index 81854c3..da6c92d 100644 --- a/common/version.go +++ b/common/version.go @@ -5,7 +5,7 @@ import "github.com/nspcc-dev/neo-go/pkg/interop/native/std" const ( major = 0 minor = 21 - patch = 2 + patch = 3 // Versions from which an update should be performed. // These should be used in a group (so prevMinor can be equal to minor if there are From fb9c8e97c2f52368980d30573d9c936f9f684d67 Mon Sep 17 00:00:00 2001 From: Dmitrii Stepanov Date: Mon, 5 May 2025 10:36:12 +0300 Subject: [PATCH 09/11] [#146] frostfsid: Store additional keys out of subject Signed-off-by: Dmitrii Stepanov --- frostfsid/doc.go | 1 + frostfsid/frostfsid_contract.go | 75 +++++++++++++++++++++++---------- tests/frostfsid_client_test.go | 22 ++++++++++ 3 files changed, 75 insertions(+), 23 deletions(-) diff --git a/frostfsid/doc.go b/frostfsid/doc.go index b3d5b82..a887f42 100644 --- a/frostfsid/doc.go +++ b/frostfsid/doc.go @@ -21,6 +21,7 @@ FrostFSID contract does not produce notifications to process. | `c` | Int | group id counter | | `m` + [ RIPEMD160 of namespace ] + [ RIPEMD160 of subject name ] | Serialized group id int | group name to group id index | | `A` + [ subject address ] | bool | means that the wallet has been used | + | `d` + [ subject address ] + [ pk address ] | []byte{1} | link subject to extra public keys | */ diff --git a/frostfsid/frostfsid_contract.go b/frostfsid/frostfsid_contract.go index d773536..9db6a54 100644 --- a/frostfsid/frostfsid_contract.go +++ b/frostfsid/frostfsid_contract.go @@ -21,6 +21,7 @@ type ( // - Name: a string representing the name of the subject. // The name must match the following regex pattern: ^[\w+=,.@-]{1,64}$ // The Subject is stored in the storage as a hash(namespace) + hash(name). + // AdditionalKeys are stored in records with subject's address prefix. Subject struct { PrimaryKey interop.PublicKey AdditionalKeys []interop.PublicKey @@ -99,6 +100,7 @@ const ( groupCounterKey = 'c' namespaceGroupsNamesPrefix = 'm' addressPrefix = 'A' + subjectToAddKeyPrefix = 'd' nsActiveState = "active" nsFrozenState = "frozen" @@ -228,7 +230,7 @@ func CreateSubject(ns string, key interop.PublicKey) { panic("subject already exists") } - saPrefix := subjectAdditionalPrefix(key) + saPrefix := additionalKeyToSubjectPrefix(key) it := storage.Find(ctx, saPrefix, storage.KeysOnly) for iterator.Next(it) { panic("key is occupied") @@ -276,7 +278,7 @@ func AddSubjectKey(addr interop.Hash160, key interop.PublicKey) { panic("key is occupied") } - saKey := subjectAdditionalKey(key, addr) + saKey := additionalKeyToSubjectKey(key, addr) data := storage.Get(ctx, saKey).([]byte) if data != nil { panic("key already added") @@ -292,9 +294,7 @@ func AddSubjectKey(addr interop.Hash160, key interop.PublicKey) { subject := std.Deserialize(data).(Subject) checkNamespaceState(subject.Namespace) - subject.AdditionalKeys = append(subject.AdditionalKeys, key) - - storage.Put(ctx, sKey, std.Serialize(subject)) + storage.Put(ctx, subjectToAdditionalKeyKey(addr, key), []byte{1}) storage.Put(ctx, addressKey, true) runtime.Notify("AddSubjectKey", addr, key) } @@ -311,7 +311,7 @@ func RemoveSubjectKey(addr interop.Hash160, key interop.PublicKey) { panic("incorrect public key length") } - saKey := subjectAdditionalKey(key, addr) + saKey := additionalKeyToSubjectKey(key, addr) data := storage.Get(ctx, saKey).([]byte) if data == nil { panic("key already removed") @@ -324,17 +324,14 @@ func RemoveSubjectKey(addr interop.Hash160, key interop.PublicKey) { if data == nil { panic("address not found") } - subject := std.Deserialize(data).(Subject) - var additionalKeys []interop.PublicKey - for i := 0; i < len(subject.AdditionalKeys); i++ { - if !common.BytesEqual(subject.AdditionalKeys[i], key) { - additionalKeys = append(additionalKeys, subject.AdditionalKeys[i]) - } + subjToAddKey := subjectToAdditionalKeyKey(addr, key) + data = storage.Get(ctx, subjToAddKey).([]byte) + if data == nil { + panic("key already removed") } - subject.AdditionalKeys = additionalKeys + storage.Delete(ctx, subjToAddKey) - storage.Put(ctx, sKey, std.Serialize(subject)) storage.Delete(ctx, addressKey(contract.CreateStandardAccount(key))) runtime.Notify("RemoveSubjectKey", addr, key) } @@ -429,8 +426,10 @@ func DeleteSubject(addr interop.Hash160) { } subj := std.Deserialize(data).(Subject) + subj.AdditionalKeys = getSubjectAdditionalKeys(ctx, addr) for i := 0; i < len(subj.AdditionalKeys); i++ { - storage.Delete(ctx, subjectAdditionalKey(subj.AdditionalKeys[i], addr)) + storage.Delete(ctx, additionalKeyToSubjectKey(subj.AdditionalKeys[i], addr)) + storage.Delete(ctx, subjectToAdditionalKeyKey(addr, subj.AdditionalKeys[i])) storage.Delete(ctx, addressKey(contract.CreateStandardAccount(subj.AdditionalKeys[i]))) } storage.Delete(ctx, addressKey(addr)) @@ -452,15 +451,17 @@ func GetSubject(addr interop.Hash160) Subject { sKey := subjectKeyFromAddr(addr) data := storage.Get(ctx, sKey).([]byte) if data == nil { - a := getPrimaryAddr(ctx, addr) - sKey = subjectKeyFromAddr(a) + addr = getPrimaryAddr(ctx, addr) + sKey = subjectKeyFromAddr(addr) data = storage.Get(ctx, sKey).([]byte) if data == nil { panic("subject not found") } } - return std.Deserialize(data).(Subject) + subj := std.Deserialize(data).(Subject) + subj.AdditionalKeys = getSubjectAdditionalKeys(ctx, addr) + return subj } // GetSubjectExtended retrieves the extended information of the subject with the specified address. @@ -506,14 +507,18 @@ func GetSubjectByKey(key interop.PublicKey) Subject { sKey := subjectKey(key) data := storage.Get(ctx, sKey).([]byte) if data != nil { - return std.Deserialize(data).(Subject) + subj := std.Deserialize(data).(Subject) + subj.AdditionalKeys = getSubjectAdditionalKeys(ctx, contract.CreateStandardAccount(key)) + return subj } addr := getPrimaryAddr(ctx, contract.CreateStandardAccount(key)) sKey = subjectKeyFromAddr(addr) data = storage.Get(ctx, sKey).([]byte) if data != nil { - return std.Deserialize(data).(Subject) + subj := std.Deserialize(data).(Subject) + subj.AdditionalKeys = getSubjectAdditionalKeys(ctx, addr) + return subj } panic("subject not found") @@ -528,6 +533,20 @@ func getPrimaryAddr(ctx storage.Context, addr interop.Hash160) interop.Hash160 { panic("subject not found") } +func getSubjectAdditionalKeys(ctx storage.Context, addr interop.Hash160) []interop.PublicKey { + var result []interop.PublicKey + subjToAddKeyPrefix := subjectToAdditionalKeyPrefix(addr) + it := storage.Find(ctx, subjToAddKeyPrefix, storage.KeysOnly|storage.RemovePrefix) + if iterator.Next(it) { + key := iterator.Value(it).([]byte) + if len(key) < interop.PublicKeyCompressedLen { + panic("invalid subject additional key") + } + result = append(result, interop.PublicKey(key[:interop.PublicKeyCompressedLen])) + } + return result +} + // GetSubjectByName retrieves the subject with the specified name within the given namespace. func GetSubjectByName(ns, name string) Subject { key := GetSubjectKeyByName(ns, name) @@ -1075,15 +1094,25 @@ func subjectKeyFromAddr(addr interop.Hash160) []byte { return append([]byte{subjectKeysPrefix}, addr...) } -func subjectAdditionalKey(additionalKey interop.PublicKey, primeAddr interop.Hash160) []byte { - return append(subjectAdditionalPrefix(additionalKey), primeAddr...) +func additionalKeyToSubjectKey(additionalKey interop.PublicKey, primeAddr interop.Hash160) []byte { + return append(additionalKeyToSubjectPrefix(additionalKey), primeAddr...) } -func subjectAdditionalPrefix(additionalKey interop.PublicKey) []byte { +func additionalKeyToSubjectPrefix(additionalKey interop.PublicKey) []byte { addr := contract.CreateStandardAccount(additionalKey) return append([]byte{additionalKeysPrefix}, addr...) } +// subjectToAdditionalKeyKey returns 'd' + [20]byte subjectAddr + [33]byte additionalKey. +func subjectToAdditionalKeyKey(subjectAddr interop.Hash160, additionalKey interop.PublicKey) []byte { + return append(subjectToAdditionalKeyPrefix(subjectAddr), additionalKey...) +} + +// subjectToAdditionalKeyPrefix returns 'd' + [20]byte subjectAddr. +func subjectToAdditionalKeyPrefix(subjectAddr interop.Hash160) []byte { + return append([]byte{subjectToAddKeyPrefix}, subjectAddr...) +} + func namespaceKey(ns string) []byte { return namespaceKeyFromHash(ripemd160Hash(ns)) } diff --git a/tests/frostfsid_client_test.go b/tests/frostfsid_client_test.go index 3d97c87..d1e21b2 100644 --- a/tests/frostfsid_client_test.go +++ b/tests/frostfsid_client_test.go @@ -653,3 +653,25 @@ func prettyPrintExtendedSubjects(subjects []*client.SubjectExtended) { fmt.Println(sb.String()) } } + +func TestFrostfsID_ConcurrentAddSubjectKey(t *testing.T) { + f := newFrostFSIDInvoker(t) + + newKey := func(t *testing.T) *keys.PrivateKey { + pk, err := keys.NewPrivateKey() + require.NoError(t, err) + return pk + } + + subjKey := newKey(t) + subjKeyAddr := subjKey.PublicKey().GetScriptHash() + invoker := f.OwnerInvoker() + invoker.Invoke(t, stackitem.Null{}, createSubjectMethod, defaultNamespace, subjKey.PublicKey().Bytes()) + + additionalKey1 := newKey(t) + additionalKey2 := newKey(t) + tx1 := invoker.PrepareInvoke(t, addSubjectKeyMethod, subjKeyAddr, additionalKey1.PublicKey().Bytes()) + tx2 := invoker.PrepareInvoke(t, addSubjectKeyMethod, subjKeyAddr, additionalKey2.PublicKey().Bytes()) + + invoker.AddBlockCheckHalt(t, tx1, tx2) +} From 883a39011d0c223d769fcb2ecb61396379ed104f Mon Sep 17 00:00:00 2001 From: Dmitrii Stepanov Date: Mon, 5 May 2025 10:38:11 +0300 Subject: [PATCH 10/11] [#146] frostfsid: Bump version and add upgrade Signed-off-by: Dmitrii Stepanov --- VERSION | 2 +- common/version.go | 2 +- frostfsid/frostfsid_contract.go | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 3b8e435..e124b61 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.21.3 +v0.21.4 diff --git a/common/version.go b/common/version.go index da6c92d..b72ea06 100644 --- a/common/version.go +++ b/common/version.go @@ -5,7 +5,7 @@ import "github.com/nspcc-dev/neo-go/pkg/interop/native/std" const ( major = 0 minor = 21 - patch = 3 + patch = 4 // Versions from which an update should be performed. // These should be used in a group (so prevMinor can be equal to minor if there are diff --git a/frostfsid/frostfsid_contract.go b/frostfsid/frostfsid_contract.go index 9db6a54..2b48d95 100644 --- a/frostfsid/frostfsid_contract.go +++ b/frostfsid/frostfsid_contract.go @@ -162,6 +162,19 @@ func _deploy(data any, isUpdate bool) { migrateNamespacesState(ctx) } + if args.version < common.GetVersion(0, 21, 4) { + it := storage.Find(ctx, subjectKeysPrefix, storage.ValuesOnly) + for iterator.Next(it) { + subject := std.Deserialize(iterator.Value(it).([]byte)).(Subject) + subjAddr := contract.CreateStandardAccount(subject.PrimaryKey) + for i := 0; i < len(subject.AdditionalKeys); i++ { + storage.Put(ctx, subjectToAdditionalKeyKey(subjAddr, subject.AdditionalKeys[i]), []byte{1}) + } + subject.AdditionalKeys = nil + storage.Put(ctx, subjectKeyFromAddr(subjAddr), std.Serialize(subject)) + } + } + return } From ae07280ae8f473184e2c5fa1873cc373f627decb Mon Sep 17 00:00:00 2001 From: Dmitrii Stepanov Date: Mon, 5 May 2025 12:19:56 +0300 Subject: [PATCH 11/11] [#146] frostfsid: Use named variable instead of `[]byte{1}` Signed-off-by: Dmitrii Stepanov --- frostfsid/frostfsid_contract.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/frostfsid/frostfsid_contract.go b/frostfsid/frostfsid_contract.go index 2b48d95..1167041 100644 --- a/frostfsid/frostfsid_contract.go +++ b/frostfsid/frostfsid_contract.go @@ -107,6 +107,8 @@ const ( nsPurgeState = "purge" ) +var dummyValue = []byte{1} + func _deploy(data any, isUpdate bool) { ctx := storage.GetContext() @@ -168,7 +170,7 @@ func _deploy(data any, isUpdate bool) { subject := std.Deserialize(iterator.Value(it).([]byte)).(Subject) subjAddr := contract.CreateStandardAccount(subject.PrimaryKey) for i := 0; i < len(subject.AdditionalKeys); i++ { - storage.Put(ctx, subjectToAdditionalKeyKey(subjAddr, subject.AdditionalKeys[i]), []byte{1}) + storage.Put(ctx, subjectToAdditionalKeyKey(subjAddr, subject.AdditionalKeys[i]), dummyValue) } subject.AdditionalKeys = nil storage.Put(ctx, subjectKeyFromAddr(subjAddr), std.Serialize(subject)) @@ -268,7 +270,7 @@ func CreateSubject(ns string, key interop.PublicKey) { storage.Put(ctx, sKey, std.Serialize(subj)) nsSubjKey := namespaceSubjectKey(ns, addr) - storage.Put(ctx, nsSubjKey, []byte{1}) + storage.Put(ctx, nsSubjKey, dummyValue) storage.Put(ctx, allAddressKey, true) runtime.Notify("CreateSubject", interop.Hash160(addr)) @@ -297,7 +299,7 @@ func AddSubjectKey(addr interop.Hash160, key interop.PublicKey) { panic("key already added") } - storage.Put(ctx, saKey, []byte{1}) + storage.Put(ctx, saKey, dummyValue) sKey := subjectKeyFromAddr(addr) data = storage.Get(ctx, sKey).([]byte) @@ -307,7 +309,7 @@ func AddSubjectKey(addr interop.Hash160, key interop.PublicKey) { subject := std.Deserialize(data).(Subject) checkNamespaceState(subject.Namespace) - storage.Put(ctx, subjectToAdditionalKeyKey(addr, key), []byte{1}) + storage.Put(ctx, subjectToAdditionalKeyKey(addr, key), dummyValue) storage.Put(ctx, addressKey, true) runtime.Notify("AddSubjectKey", addr, key) } @@ -938,7 +940,7 @@ func AddSubjectToGroup(addr interop.Hash160, groupID int) { } gsKey := groupSubjectKey(subject.Namespace, groupID, addr) - storage.Put(ctx, gsKey, []byte{1}) + storage.Put(ctx, gsKey, dummyValue) runtime.Notify("AddSubjectToGroup", addr, subject.Namespace, groupID) }