Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 55 additions & 53 deletions acapy_agent/anoncreds/revocation/revocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1489,13 +1489,37 @@ async def handle_full_registry_event(
)
await self.notify(event)

async def _get_backup_registry_id(self, cred_def_id: str) -> str:
"""Return the rev reg def id of the current backup (finished, not active)."""
async with self.profile.session() as session:
entries = await session.handle.fetch_all(
CATEGORY_REV_REG_DEF,
{
"active": "false",
"cred_def_id": cred_def_id,
"state": RevRegDefState.STATE_FINISHED,
},
limit=1,
)
if not entries:
raise AnonCredsRevocationError(
"No backup registry available for rotation. "
"Ensure the credential definition was created with a backup registry."
)
return entries[0].name

async def decommission_registry(self, cred_def_id: str) -> list:
"""Decommission post-init registries and start the next registry generation."""
"""Rotate: set backup active, create new backup, decommission old active.

Works with endorsement: the new registry is sent to the endorser and may not be
on the ledger yet, so we promote the existing backup to active (it is already
on the ledger) and treat the newly created registry as the new backup.
"""
active_reg = await self.get_or_create_active_registry(cred_def_id)
backup_rev_reg_def_id = await self._get_backup_registry_id(cred_def_id)

# create new one and set active
LOGGER.debug("Creating new registry to replace active one")
new_reg = await asyncio.shield(
LOGGER.debug("Creating new backup registry")
new_backup_reg = await asyncio.shield(
self.create_and_register_revocation_registry_definition(
issuer_id=active_reg.rev_reg_def.issuer_id,
cred_def_id=active_reg.rev_reg_def.cred_def_id,
Expand All @@ -1504,66 +1528,44 @@ async def decommission_registry(self, cred_def_id: str) -> list:
max_cred_num=active_reg.rev_reg_def.value.max_cred_num,
)
)
# set new as active...
if new_reg and not isinstance(new_reg, str):
new_rev_reg_def_id = new_reg.rev_reg_def_id
# Store the registry definition synchronously before setting it as active
# This ensures the registry is available in the wallet when
# set_active_registry tries to fetch it, avoiding a race condition
# with async event processing
await self.store_revocation_registry_definition(new_reg)
await self.set_active_registry(new_rev_reg_def_id)
new_rev_reg_def_id = None
if new_backup_reg and not isinstance(new_backup_reg, str):
new_rev_reg_def_id = new_backup_reg.rev_reg_def_id
await self.store_revocation_registry_definition(new_backup_reg)
elif isinstance(new_backup_reg, str):
LOGGER.error("Failed to create new backup registry: %s", new_backup_reg)
else:
new_rev_reg_def_id = None
if isinstance(new_reg, str):
LOGGER.error(f"Failed to create new registry: {new_reg}")
else:
LOGGER.warning("No new registry created while decommissioning registry")
LOGGER.warning("No new backup registry created while decommissioning")

await self.set_active_registry(backup_rev_reg_def_id)

keep_ids = {backup_rev_reg_def_id}
if new_rev_reg_def_id:
keep_ids.add(new_rev_reg_def_id)

# decommission everything except init/wait
async with self.profile.transaction() as txn:
registries = await txn.handle.fetch_all(
CATEGORY_REV_REG_DEF,
{
"cred_def_id": cred_def_id,
},
{"cred_def_id": cred_def_id},
for_update=True,
)

def filter_registries(registry: Entry) -> bool:
return registry.tags.get("state") != RevRegDefState.STATE_WAIT

recs = list(filter(filter_registries, registries))

recs = [
r for r in registries if r.tags.get("state") != RevRegDefState.STATE_WAIT
]
for rec in recs:
if rec.name != new_rev_reg_def_id:
tags = rec.tags
tags["active"] = "false"
tags["state"] = RevRegDefState.STATE_DECOMMISSIONED
await txn.handle.replace(
CATEGORY_REV_REG_DEF,
rec.name,
rec.value,
tags,
)
if rec.name in keep_ids:
continue
tags = rec.tags
tags["active"] = "false"
tags["state"] = RevRegDefState.STATE_DECOMMISSIONED
await txn.handle.replace(CATEGORY_REV_REG_DEF, rec.name, rec.value, tags)
await txn.commit()
# create a second one for backup, don't make it active
LOGGER.debug("Creating backup registry")
backup_reg = await asyncio.shield(
self.create_and_register_revocation_registry_definition(
issuer_id=active_reg.rev_reg_def.issuer_id,
cred_def_id=active_reg.rev_reg_def.cred_def_id,
registry_type=active_reg.rev_reg_def.type,
tag=self._generate_backup_registry_tag(),
max_cred_num=active_reg.rev_reg_def.value.max_cred_num,
)
)

LOGGER.debug(
"New registry = %s.\nBackup registry = %s.\nDecommissioned registries = %s",
new_reg,
backup_reg,
recs,
"Rotation done: backup %s set active, new backup=%s, decommissioned=%s",
backup_rev_reg_def_id,
new_rev_reg_def_id,
[r.name for r in recs if r.name not in keep_ids],
)
return recs

Expand Down
165 changes: 144 additions & 21 deletions acapy_agent/anoncreds/revocation/tests/test_revocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -881,25 +881,48 @@ async def test_handle_full_registry(self, mock_set_active_registry, mock_handle)

@mock.patch.object(AskarAnonCredsProfileSession, "handle")
async def test_decommission_registry(self, mock_handle):
# First fetch_all: find backup (active=false, state=finished)
# Second fetch_all: in transaction, get all registries for cred_def_id
mock_handle.fetch_all = mock.CoroutineMock(
return_value=[
MockEntry(
name="active-reg-reg",
tags={
"state": RevRegDefState.STATE_FINISHED,
"active": True,
},
),
MockEntry(
name="new-rev-reg",
tags={
"state": RevRegDefState.STATE_FINISHED,
"active": True,
},
),
side_effect=[
[
MockEntry(
name="backup-reg-reg",
tags={
"cred_def_id": "test-rev-reg-def-id",
"state": RevRegDefState.STATE_FINISHED,
"active": "false",
},
),
],
[
MockEntry(
name="active-reg-reg",
tags={
"cred_def_id": "test-rev-reg-def-id",
"state": RevRegDefState.STATE_FINISHED,
"active": "true",
},
),
MockEntry(
name="backup-reg-reg",
tags={
"cred_def_id": "test-rev-reg-def-id",
"state": RevRegDefState.STATE_FINISHED,
"active": "false",
},
),
MockEntry(
name="new-rev-reg",
tags={
"cred_def_id": "test-rev-reg-def-id",
"state": RevRegDefState.STATE_FINISHED,
"active": "false",
},
),
],
]
)
# active registry
self.revocation.get_or_create_active_registry = mock.CoroutineMock(
return_value=RevRegDefResult(
job_id="test-job-id",
Expand All @@ -912,7 +935,7 @@ async def test_decommission_registry(self, mock_handle):
revocation_registry_definition_metadata={},
)
)
# new active
# New backup only (one call); previous backup becomes active
self.revocation.create_and_register_revocation_registry_definition = (
mock.CoroutineMock(
return_value=RevRegDefResult(
Expand All @@ -936,18 +959,118 @@ async def test_decommission_registry(self, mock_handle):
result = await self.revocation.decommission_registry("test-rev-reg-def-id")

assert isinstance(result, list)
assert len(result) == 2
assert len(result) == 3
# First entry (active-reg-reg) is decommissioned; backup and new backup kept
assert result[0].tags["active"] == "false"
assert result[0].tags["state"] == RevRegDefState.STATE_DECOMMISSIONED
assert mock_handle.fetch_all.called
assert mock_handle.replace.called
# Verify store_revocation_registry_definition was called before set_active_registry
self.revocation.store_revocation_registry_definition.assert_called_once()
# # One for backup
# Previous backup set as active (works with endorsement: backup already on ledger)
self.revocation.set_active_registry.assert_called_once_with("backup-reg-reg")
# One new backup registry created (not two)
assert (
self.revocation.create_and_register_revocation_registry_definition.call_count
== 2
== 1
)

@mock.patch.object(AskarAnonCredsProfileSession, "handle")
async def test_decommission_registry_no_backup_raises(self, mock_handle):
"""When no backup registry exists, rotation raises."""
mock_handle.fetch_all = mock.CoroutineMock(return_value=[])
self.revocation.get_or_create_active_registry = mock.CoroutineMock(
return_value=RevRegDefResult(
job_id="test-job-id",
revocation_registry_definition_state=RevRegDefState(
state=RevRegDefState.STATE_FINISHED,
revocation_registry_definition_id="active-reg-reg",
revocation_registry_definition=rev_reg_def,
),
registration_metadata={},
revocation_registry_definition_metadata={},
)
)
with self.assertRaises(test_module.AnonCredsRevocationError) as cm:
await self.revocation.decommission_registry("test-rev-reg-def-id")
self.assertIn("No backup registry available", str(cm.exception))

@mock.patch.object(AskarAnonCredsProfileSession, "handle")
async def test_decommission_registry_new_backup_creation_fails(self, mock_handle):
"""When creating the new backup fails, we still promote backup to active and decommission old."""
backup_entry = MockEntry(
name="backup-reg-reg",
tags={
"cred_def_id": "test-rev-reg-def-id",
"state": RevRegDefState.STATE_FINISHED,
"active": "false",
},
)
mock_handle.fetch_all = mock.CoroutineMock(
side_effect=[
[backup_entry],
[
MockEntry(
name="active-reg-reg",
tags={
"cred_def_id": "test-rev-reg-def-id",
"state": RevRegDefState.STATE_FINISHED,
"active": "true",
},
),
backup_entry,
],
]
)
mock_handle.replace = mock.CoroutineMock(return_value=None)
self.revocation.get_or_create_active_registry = mock.CoroutineMock(
return_value=RevRegDefResult(
job_id="test-job-id",
revocation_registry_definition_state=RevRegDefState(
state=RevRegDefState.STATE_FINISHED,
revocation_registry_definition_id="active-reg-reg",
revocation_registry_definition=rev_reg_def,
),
registration_metadata={},
revocation_registry_definition_metadata={},
)
)
self.revocation.create_and_register_revocation_registry_definition = (
mock.CoroutineMock(return_value="Failed to create new registry")
)
self.revocation.set_active_registry = mock.CoroutineMock(return_value=None)

result = await self.revocation.decommission_registry("test-rev-reg-def-id")

self.revocation.set_active_registry.assert_called_once_with("backup-reg-reg")
assert mock_handle.replace.call_count == 1
assert len(result) == 2
assert result[0].tags["state"] == RevRegDefState.STATE_DECOMMISSIONED

@mock.patch.object(AskarAnonCredsProfileSession, "handle")
async def test_get_backup_registry_id_raises_when_no_backup(self, mock_handle):
"""_get_backup_registry_id raises when no finished backup exists."""
mock_handle.fetch_all = mock.CoroutineMock(return_value=[])
with self.assertRaises(test_module.AnonCredsRevocationError) as cm:
await self.revocation._get_backup_registry_id("test-cred-def-id")
self.assertIn("No backup registry available", str(cm.exception))

@mock.patch.object(AskarAnonCredsProfileSession, "handle")
async def test_get_backup_registry_id_returns_first_backup(self, mock_handle):
"""_get_backup_registry_id returns the name of the first matching backup."""
mock_handle.fetch_all = mock.CoroutineMock(
return_value=[
MockEntry(
name="backup-id-123",
tags={
"cred_def_id": "test-cred-def-id",
"state": RevRegDefState.STATE_FINISHED,
"active": "false",
},
),
]
)
result = await self.revocation._get_backup_registry_id("test-cred-def-id")
assert result == "backup-id-123"

@mock.patch.object(AskarAnonCredsProfileSession, "handle")
async def test_get_or_create_active_registry(self, mock_handle):
Expand Down
Loading