Skip to content

feat(security): add issuer role separation controls - #177

Merged
El-swaggerito merged 1 commit into
Raegis-RWA:mainfrom
Fury03:feat/issuer-role-separation
Jul 29, 2026
Merged

feat(security): add issuer role separation controls#177
El-swaggerito merged 1 commit into
Raegis-RWA:mainfrom
Fury03:feat/issuer-role-separation

Conversation

@Fury03

@Fury03 Fury03 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #28

The role model answers which privileges an address holds. It does not answer the question an RWA auditor asks first: can the same key both decide who may hold the asset and decide who receives it?

Until now the answer was yes. The supreme admin bypasses every role check, so a single admin key can approve an address and then mint to it — no second party involved, and nothing in the contract to notice. That is the control failure behind self-allocation and fictitious-holder issuance fraud: not a bug in any one function, but the absence of a constraint between two correctly-functioning ones.

This PR adds that constraint as an opt-in policy, so no existing deployment's behaviour changes until an admin enables it.

Duties, not role names

The duty map is derived from what the contract enforces, not from what a role is named after:

Role Compliance Issuance Emergency Governance
ComplianceOfficer
AssetManager
EmergencyOfficer
Admin

Documentation correction found while implementing this. docs/admin-roles.md listed mint_asset and distribute_yield among EmergencyOfficer's privileged operations. That has never been true in the code — both call require_role(AssetManager), which admits only an AssetManager or the admin, and an EmergencyOfficer minting is rejected with Unauthorized (3000). I fixed the doc rather than widening the role, since widening a privilege is the opposite of what this issue asks for. A separation control built on an inaccurate privilege map is worse than none, so it is stated here rather than quietly corrected.

The consequence: today the admin is the only address carrying both duties, so the dual-duty control is in practice what forces an admin to delegate issuance to a dedicated AssetManager key. It is written against duties rather than against "the admin" so it keeps working if a future role combines them.

The controls

Control Refuses when Error
allow_dual_duty_issuance: false The caller carries both the compliance and issuance duties IssuanceDutyConflict (3007)
allow_self_issuance: false The caller is the recipient SelfIssuanceForbidden (3008)
require_independent_approver: true The caller approved the recipient's compliance (four-eyes) IssuanceApproverConflict (3009)

Each is independent, so an issuer adopts only the parts their operating model supports. DataKey::ComplianceApprover records who last moved an address into Approved — written by set_compliance_status, whitelist_user, and batch updates; a revocation does not erase it, since revoking clearance should not erase who granted the clearance being revoked.

Never self-locking. set_issuer_separation_policy is deliberately not gated by the policy it sets, so the strictest configuration is always recoverable — verified by test_separation_policy_can_never_lock_a_deployment_out_of_issuance.

Pre-flight read. check_issuance_authority(caller, recipient) returns the verdict from the same evaluation mint_asset enforces, along with the caller's effective role and duties and the recipient's recorded approver, so a dashboard can explain a refusal rather than just report one.

Notes for the reviewer

  • Backwards compatible by construction. The default policy is fully permissive; all 180 pre-existing tests pass unmodified.
  • Two pre-existing failures on main are fixed here so the suite is green. Unrelated to this issue and kept minimal: fixtures/sdk/01-compliance.json had drifted, and fixture_errors asserted AssetNotActive (6000) / AssetLifecyclePaused (6001) for mints the contract already reports as 7002 / 7000docs/error-codes.md already documents 6000 as "Reserved. Superseded by 7000–7002; no longer emitted", so the expectations were stale, not the contract. cargo fmt also normalized src/config.rs / src/config_test.rs, unformatted on main. The same three fixes appear in the PR for Add compliance status transition guards #27; whichever merges second can drop them.
  • Capability schema. This bumps CAPABILITY_SCHEMA_VERSION 3 → 4. The PR for Add compliance status transition guards #27 does the same; whichever merges second should become 5.

Test Evidence

cargo test                                     196 passed; 0 failed   (180 pre-existing + 16 new)
cargo test --test sdk_fixtures                  10 passed; 0 failed
cargo fmt --all -- --check                      clean
cargo build --target wasm32v1-none --release    ok

test_check_issuance_authority_matches_mint_enforcement is the load-bearing one: five policies × four caller classes × two recipients, each on a fresh deployment, asserting the pre-flight verdict and the real mint agree on outcome, error code, and resulting balance. Every recipient in that matrix is compliance-approved first, so the only thing that can refuse a mint there is a separation control.

Completion Table

Acceptance Criterion Status Implementation Evidence Test Evidence Documentation Impact
AC 1: Issuer role separation is implemented or clearly specified Complete src/issuer.rsIssuerDuty / role_has_duty() (duty map), IssuerSeparationPolicy (three controls), evaluate_issuance() / require_issuance_authority() (guard, called from mint_asset and distribute_yield), set_issuer_separation_policy() (governance) test_role_duty_table_is_exact, test_dual_duty_issuance_is_refused_when_separation_is_enforced, test_self_issuance_is_refused_when_disallowed, test_independent_approver_control_enforces_four_eyes docs/issuer-role-separation.md added
AC 2: Relevant edge cases and failure states are handled Complete Uninitialized contract, paused contract, non-issuer caller, admin dual duty, self-issuance, approver identity, yield distribution (no beneficiary), approver record across revoke/re-approve/batch/legacy paths, lockout recovery test_issuance_guard_reports_not_initialized_instead_of_panicking, test_policy_update_is_blocked_while_paused, test_missing_issuance_duty_is_reported_separately_from_a_separation_failure, test_approver_record_tracks_every_approval_path, test_yield_distribution_respects_the_duty_control_only, test_separation_policy_can_never_lock_a_deployment_out_of_issuance, test_separation_controls_are_independent Evaluation-order and approver-record sections in docs/issuer-role-separation.md
AC 3: Security and compliance-sensitive assumptions are documented Complete Explicitly not a defence against a compromised admin key; two keys ≠ two people; only the most recent approver is enforced against; duties derived at call time; verdicts are point-in-time; the policy is a control, not an attestation test_issuance_reads_never_mutate_state, test_policy_update_is_admin_only_and_emits_the_previous_policy "Security and compliance assumptions" (6 numbered) + not-legal-advice notice in docs/issuer-role-separation.md; cross-links to admin-misuse-risks.md / threat-model.md
AC 4: Tests, fixtures, or review checklists are added Complete 16 tests under ISSUER ROLE SEPARATION in src/test.rs; 2 new SDK fixture scenarios; a recommended operating model reviewers can check against cargo test 196 passed; fixtures/sdk/05-errors.jsonerror-3007-issuance-duty-conflict; fixtures/sdk/04-events.jsonevent-issuer-separation-policy-updated Test-coverage table + "Recommended operating model" in docs/issuer-role-separation.md
AC 5: README or docs link to the new guidance Complete README "Security & Compliance" entry; admin-roles.md gains a Separation of Duties section and the corrected role table N/A — documentation only README.md, docs/admin-roles.md, docs/error-codes.md (3007–3009 + client guidance), docs/events.md (new topic)
AC 6: The change is compatible with the rest of the Aegis ecosystem Complete Permissive default preserves all existing behaviour; minting.issuer_separation capability + issuer_separation registry key + issuer_separation_enforced runtime flag; IssuerDuty / IssuanceGuard / IssuerSeparationPolicy documented as append-only ABI All 180 pre-existing tests pass unmodified; the fixture harness's event-coverage guard is satisfied for the new topic docs/capabilities.md (fields, registry key, version note)

Contributor Self-Assessment

  • Scope Confirmation: Changes match issue scope. The admin-roles.md correction and the three pre-existing repo fixes are called out above.
  • Test Evidence: All tests pass locally (206 total across both suites).
  • CI Status: fmt-check, clippy (no new warnings from the new module), test, and the wasm release build all pass locally.
  • Known Limitations: Not a defence against a compromised admin key; the contract observes distinct addresses, not distinct people; only the most recent approver is enforced against. All three are documented as explicit assumptions.
  • Acceptance Criteria: Every criterion verified in the table above.

Protocol-level access controls only. Nothing here is legal or financial advice — see docs/legal-boundary-disclaimer.md.

@El-swaggerito

Copy link
Copy Markdown
Contributor
\nThis PR is currently blocked by merge conflicts.\n\nPlease update the branch with the latest main branch and resolve the conflicts before it can be merged.

Adds separation-of-duties controls for issuance, so a deployment can require
that clearing an investor and funding that investor be two different keys.

- src/issuer.rs: duty model (Compliance / Issuance / Emergency / Governance)
  derived from what the contract enforces, an opt-in IssuerSeparationPolicy
  with three independent controls (dual-duty, self-issuance,
  independent-approver), the non-panicking guard both mint_asset and the
  pre-flight read share, and admin-governed policy updates.
- DataKey::ComplianceApprover records who last moved an address into Approved,
  written by every approval path; a revocation does not erase it.
- New errors 3007 IssuanceDutyConflict, 3008 SelfIssuanceForbidden,
  3009 IssuanceApproverConflict; new event
  issuer_separation_policy_updated carrying both the previous and new policy.
- Default policy is fully permissive, so no existing deployment's behaviour
  changes; the policy setter is not gated by the policy it sets, so the
  strictest configuration can always be recovered from.
- Corrects docs/admin-roles.md: EmergencyOfficer was listed as able to
  mint_asset / distribute_yield, but both call require_role(AssetManager) and
  have always rejected it. The duty table now matches the contract.
- 16 tests, including a five-policy x four-caller x two-recipient matrix
  asserting the pre-flight read and the real mint always agree.
- docs/issuer-role-separation.md with the duty map, controls, assumptions,
  and a recommended operating model; README, admin-roles, error-codes,
  events, capabilities (schema v4), and SDK fixtures updated.
@Fury03
Fury03 force-pushed the feat/issuer-role-separation branch from 2ee3296 to c84ba99 Compare July 29, 2026 15:02
@El-swaggerito
El-swaggerito merged commit 1256e55 into Raegis-RWA:main Jul 29, 2026
1 check passed
@Fury03

Fury03 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main after #171 merged. Two notes on the description above, now that the base has moved:

Post-rebase verification: cargo test 221 passed / 0 failed, cargo test --test sdk_fixtures 10 passed / 0 failed, cargo fmt --all -- --check clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement issuer role separation

2 participants