RBAC & Permissions
Access control in Aset spans two layers that must be read together:
- Off-chain (admin panel / Lambda) — who can press a button or call an API. Four panel roles: Super Admin · Admin · Operator · Fund Manager. Enforced by
withRole()+admin_user_permissions. - On-chain (contracts) — whose signed transaction the contract accepts. Four on-chain roles: Admin (Governance) · Pauser · Service Key · Yield Depositor. Admin + Pauser run on an offline cold hardware key (multisig structure retained in-contract); Service Key is the hot automated key.
🔑 Non-Custodial Principle (the rule everything follows)
Aset never holds a key that moves user funds. Investors sign their own deposit, redemption request, and yield claim; the partner signs depositYield / fundRedemption. The redemption payout auto-executes via the bounded Service Key to a fixed destination — the original requester (LP locked at request) — so no key can redirect funds. The only human-controlled keys are Admin (governance) and Pauser (emergency), held on an offline cold hardware key (1 primary + 1 backup, kept on-premise; see Key custody below). Everything else is either a bounded automated Service Key (can't move funds to an arbitrary destination) or self-custody. Approvals are non-custodial gates, not transfers. → No VASP custody classification. Overall determination and the 6 criteria: 09a-custody. See Pool Models → Non-Custodial.
Off-chain Panel Roles
🔒 Super Admin — Platform owner
- Panel:
/admin· Scope: everything · Auth: Email + Password (backend-only creation, 1 per platform) - Login route: unlisted
/super(break-glass). NOT linked from/login; the Super Admin navigates to it directly. The Email/PW form exists only at/super— the public/loginshows Google OAuth only (no password field surfaced). 🔒 INTERNAL — exclude from any future user-facing / B2B docs (do not reveal this route or the password path to users). - Exclusive: invite/remove Admins, platform config, role admin
👑 Admin — Full platform control
- Panel:
/admin· Scope: all pools/funds · Auth: Google OAuth (invited by Super Admin) - Authorizes governance/lifecycle (executed on-chain via the Admin cold key), approves redemptions, configures pools, invites Operators/FMs
⚙️ Operator — Day-to-day operations
- Panel:
/admin· Scope: all pools (page-level grants) · Auth: Google OAuth - Triggers yield distribution accounting, edits low-impact pool config, monitors deposits/redemptions
📁 Fund Manager — Fund-scoped, key-free approver
- Panel: the same admin app, role-scoped — there is no separate
/fund-adminroute. The FM signs into the standard admin panel; the sidebar is filtered by role permissions and all data is scoped to the FM's own fund(s) server-side. · Scope: own fund(s) only · Auth: Google OAuth — no wallet/key - Authorizes (off-chain gates only): configure yield settings, manage fund members, view fund-scoped dashboard. No redemption decision at all — v3-82 made settlement automatic (reserve-covered completes on request; a shortfall escrows and completes the moment partner funding lands), so the only decision left is
Return position, and v3-99 reserved that for ADMIN / SUPER_ADMIN: its main use is closing a shortfall the fund never funded, and the FM is that fund. What the FM keeps is the shortfall alert, read access, and signingfundRedemptionfrom their own wallet. See Redemption exit gates. - Per-page visibility, data-scoping rules, and required route guards: see Fund Manager — Panel Scoping below (this is the backend implementation spec).
📁 FM panel role ≠ the fund's wallet
The Fund Manager panel role is a key-free off-chain approver. The fund's on-chain money-mover is a separate thing: the partner's fund_wallet (a Gnosis Safe holding the Yield Depositor role), which signs depositYield / fundRedemption with its own keys (self-custody). Conflating these two was the v2.x mistake. They can be the same organization wearing two hats, but the authority is split: approve = OAuth, no key / move funds = own wallet signature.
⚙️ Operator Page-Level Permissions
Operator permissions are page-level, controlled via admin_user_permissions. Page keys (9, ALL_PAGE_KEYS in admin-web/app/shared/lib/auth/types.ts): 'dashboard', 'deposits', 'redemptions', 'yield', 'pools', 'kyc', 'fund_managers', 'activity', 'notifications'. Super Admins and Admins have full access (no records needed). Operator defaults to dashboard / deposits / redemptions; notifications is gated for Operator (S-16). Fund Manager defaults to dashboard / deposits / redemptions / yield / pools / fund_managers / activity — the fund-scoped, operational-only view of the Audit Log route (v3-42; the two-stream feed itself is v3-41) — and sees only their assigned fund's data. kyc and admin settings are excluded from FM.
On-chain Roles (4)
Single DEFAULT_ADMIN is split into four roles by (key holder × timing × risk). Governance and emergency run on a human-controlled cold key (multisig-capable in-contract, see Key custody); everything else is a bounded automated key or self-custody. (Benchmark: Aave V3 ~4 core admin roles, Trail of Bits "≥4 + timelocks", OpenZeppelin "too many roles are hard to manage" — 4 is the sweet spot.)
| Role | Responsibility | Held by | Safeguard |
|---|---|---|---|
| Admin (Governance) | params (fund_wallet / reserve% / KYC / lockup) · lifecycle (impairment / wind-down) · treasury address · role admin | Cold key (offline HW, 1 + 1 backup, on-prem) | 7-day / 30-day timelock |
| Pauser (Guardian) | pause / is_emergency_frozen + release | Cold key (offline HW) | No timelock (instant), halt-only — no fund movement |
| Service Key (Oracle / Ops) | updateNAV · settleYield (pro-rata accounting + fee legs to fixed destinations) · approveRedemption gate | Hot key — Aset automated (Lambda) | NAV: ≤1.0 cap + 24h decrease timelock + per-update deviation cap / circuit breaker now in the immutable core (v3-32, implemented) so a compromised hot key cannot move NAV beyond the bound · fees: address fixed · all non-custodial |
| Yield Depositor (Partner) | depositYield · fundRedemption | Partner's fund_wallet (Safe) | Single-purpose · partner self-custody (not an Aset role) |
Why no Treasurer role
Fee withdrawal is not a standalone action to guard: since v3-102 it is the two fee legs inside settleYield (there is no withdrawFees function any more), so it happens in the same transaction that credits investors and cannot be invoked on its own. Those legs move Aset's own fee revenue to fixed, non-parameter destinations — the treasuryWallet and the per-pool fundFeeWallet — bounded by accumulated yield, so they cannot touch user principal or reserve. A separate human multi-sig for it duplicates the Admin profile without reducing real risk. So fee withdrawal lives on the Service Key, and the treasury / fund_fee_wallet address change requires the Admin key (multi-sig) — but per v3-69 these fee destinations are not timelocked (fee auto-distributes with no hold, so a timelock would only strand fees at a stale address; only the deposit fund_wallet keeps its timelock). If stricter least-privilege is ever wanted, the valuable split is Oracle (NAV-only) ↔ Ops — not a Treasurer (NAV is the crown jewel that scales all payouts).
🔴 The grant that named the wrong key — every pool created since the split (v3-138)
Fixed in the deploy worker, not deployed. Pre-existing; unrelated to the change that found it
Before the KMS key split (2026-08-14) the admin and oracle identities were the same key, so granting a role to the deploying account granted it to the signer by accident. The split made them distinct addresses, and create-pool.ts went on granting ORACLE_ROLE to account.address — the admin signer. No pool has been created since, so the defect shipped and waited rather than being caught.
| Where | What was wrong |
|---|---|
create-pool.ts | grantRole(ORACLE_ROLE, account.address) named the admin signer, not the oracle one |
handler-roles.ts | 9 call sites sign as oracle; that address held no role |
PlatformPool.initialize:784 | PAUSER_ROLE went to _admin, the deploy key, and the operational grant list never mentioned PAUSER at all |
A pool born in that state reverts on every oracle path — NAV marks, redemption approve and reject, yield settlement, the epoch schedule, the tranche write-down — and on all 5 PAUSER-gated functions: pause, unpause, emergencyFreeze, unfreeze, tripCircuitBreaker.
⚠️ Close, wind-down and impairment were never broken. setLifecycleStatus is DEFAULT_ADMIN_ROLE, which the deploy key holds. What did fail is the clearOnChainPause leg those three handlers run alongside the escalation (v3-92), because it calls unpause.
The fix: grant each role to the address that actually signs for it, and add oracle and pauser policies to the deploy worker. The bootstrap PAUSER_ROLE on the deploy key is then taken back — the comment above that grant in PlatformPool.initialize already described the hand-off as the intended end state, and now names the code that performs it.
🔴 Grant the new holder before revoking the old one. If the sequence dies half-way, what has to survive is a pool somebody can still halt; revoke-then-grant leaves the opposite. Pinned by a test comparing the two call indices, not merely asserting both calls exist.
Key custody — Cold / Hot split
🔑 Cold / Hot key model (decided 2026-06-15)
The two human-controlled on-chain roles — Admin (Governance) and Pauser — are held on an offline cold hardware key: 1 primary + 1 backup, kept on-premise (must not leave the company). The Service Key is the hot key (Aset automated Lambda key).
- Cold keys (offline HW, on-prem) → Admin (params / lifecycle / treasury address / role admin) and Pauser (pause / emergency freeze), held on separate Gnosis Safe multisigs (v3-37): Admin 3-of-5, Pauser a separate 2-of-3 (lower threshold for fast freeze). Timelocks unchanged: Admin 7-day / 30-day, Pauser instant (halt-only, no fund movement).
- Hot key → Service Key only (
updateNAV/settleYield/ redemption gate) — bounded and non-custodial. - Multisig adopted (v3-37): the contract's role structure is multisig-ready, so this needs no contract change (grant each role to a Safe;
hasRoleis indifferent to EOA vs Safe). Per the 2026-06-23 security review: Admin → 3-of-5 cold Safe, Pauser → a separate 2-of-3 cold Safe (split so emergency freeze acts fast at a lower threshold — safe because freeze is halt-only + auto-expiring, v3-28). Thresholds provisional (cost / signer availability). A genuinely new signer-combination tier (e.g. "1 cold + 1 hot") would instead need a new role + redeploy — not being done. - Yield Depositor is unchanged — partner
fund_walletself-custody (not an Aset key).
✅ Non-custody key governance — Decided + implemented (v3-27 / v3-28 / v3-30 / v3-31 / v3-32)
For a single cold key to be non-custodial, the governance items below must hold. All are decided, and the contract pieces are now implemented (2026-06-19):
- (v3-27) Money-path immutable — the user-funds path (redemption payout =
request.investor, the LP-locked verified holder set at request; yield = current holder;settleYield's fee legs = bounded by accumulated yield → fixed destinations) is excluded from any upgrade scope. The money-path contracts (PlatformPool/PlatformLPToken) are non-upgradeableClones(fixed implementation) — no_authorizeUpgrade, no destination setter. No multi-sig needed. - (v3-30) KYC is the only upgradeable piece —
PlatformKYCSoulboundis an ERC1967 UUPS proxy with a propose/execute gate (proposeUpgrade→executeUpgrade/cancelUpgrade);_authorizeUpgradeaccepts only that path. ⚠️ The 7-dayUPGRADE_TIMELOCKwas removed 2026-08-14 (v3-71) — an upgrade now takes effect as fast as two transactions, and the KYC contract is what every exit path reads (canRedeem). Compliance logic can evolve without pool migration, while the money-path stays immutable. Invariant: KYC gates eligibility only — it can never move funds or change a payout destination. - (v3-28) Emergency-freeze is time-bound — instant freeze is retained but cannot trap indefinitely: capital-IN is blocked for the whole freeze, but value-OUT (redemption/claim) auto-unblocks after 72h (
FREEZE_EXIT_WINDOW) and the entire freeze auto-expires after 7d (FREEZE_MAX_DURATION). There is no on-chain way to prolong it: theFreezeExtendtriple this once described was removed because a 7-day governance timelock on a 7-day freeze could only execute after the freeze had already lapsed, which either did nothing or retroactively re-locked the pool and restarted the 72h exit block. To escalate past 7d, usepause(capital-in only) orimpairment— both of which leave exits open, which is the property v3-28 exists to protect.unfreezestays PAUSER so recovery is fast. - (v3-31) Permissionless exit fallbacks — 🔴 HALF OF THIS IS GONE (2026-08-31). Epoch pools: unchanged and still on chain — permissionless
executeEpoch+ claim-on-behalf, so a settled cycle pays out whether or not Aset acts. Instant pools:claimRedemptionFallbackis REMOVED. It settled from the pool's reserve, and the reserve is routed toreserve_walletat deposit, so its available balance is structurally zero and it could not have freed anyone. An instant-pool holder now depends on the reserve wallet funding their request, which is an operational commitment rather than a contract one.approveRedemptionis removed for the same reason. - (v3-32) NAV bound in core + role-admin acceptance — the
updateNAVdeviation cap / circuit breaker now lives in the immutable core, so even a compromised hotORACLEkey (or a self-grantedORACLErole) cannot move NAV beyond a small bound. With theft off the table (immutable money-path) and exit guaranteed (v3-31), a single role-admin cold key + timelock is accepted — no multisig required
🔴 TODO(review, 2026-08-31): this argument's second premise weakened. "Exit guaranteed" leaned on v3-31, and instant pools no longer have an on-chain exit that works without the operator. Whether a single cold key is still acceptable on that basis is a governance judgement, not a docs edit. (a cold multisig stays optional defense-in-depth → adopted in v3-37: Admin 3-of-5, Pauser a separate 2-of-3).
Details: 14-decisions v3-27 / v3-28 / v3-30 / v3-31 / v3-32 · 09a-custody · Custody vs Non-Custody (Notion, criterion #1 unilateral control · #5 exit right).
Off-chain ↔ On-chain Mapping
Each action: who authorizes it off-chain → how it executes on-chain → who signs → is a human-controlled key involved.
| Action | Off-chain authorizer | On-chain role | Who signs | Human key? |
|---|---|---|---|---|
| param / lifecycle / treasury-address change | Super Admin / Admin | Admin (Governance) | Cold key (HW) | 🔑 cold key + timelock |
| pause / emergency freeze | Admin | Pauser | Cold key (HW) | 🔑 cold key, instant |
updateNAV | (oracle bot) | Service Key | Aset key | ❌ bounded |
settleYield (pro-rata accounting + fee legs) | Admin / Operator | Service Key | Aset key | ❌ funds already in pool · fee addresses fixed |
| redemption approval (gate) | FM / Admin | Service Key (gate, embeds FM id) | Aset gate key | ❌ no fund movement |
| redemption payout | — | Service Key (auto-execute) | → requester (locked State-A holder) | ❌ fixed destination |
depositYield | FM (in-panel) | Yield Depositor | FM's fund_wallet (client-signed in admin panel, v3-53) | ❌ self-custody |
fundRedemption (shortfall) | FM (in-panel) | Yield Depositor | FM's fund_wallet (client-signed in admin panel, v3-53) | ❌ self-custody |
| yield claim | — | — | Investor | ❌ self-custody |
🛄 FM client-signs the two money-in functions (v3-53)
The only two functions the contract gates to the partner — depositYield and fundRedemption (YIELD_DEPOSITOR_ROLE) — are client-signed by the Fund Manager from their own wallet inside the admin panel, not by any Aset key. Model A: the FM's connected wallet is the pool's on-chain fund_wallet (no separate operator wallet, no contract change). This keeps the panel role vs the fund's wallet distinction (above) intact — the FM still authorizes off-chain via Google OAuth and has no Aset-held key; the on-chain signature comes from the FM's self-custodied fund_wallet (EOA or Safe).
- Wallet ownership proof (B3): the FM proves ownership of a signing wallet via SIWE (
POST /admin/wallet/nonce→/admin/wallet/verify, session-less — no new JWT, the Google session is untouched). Proven wallets are stored inadmin_user_wallets(one FM → N wallets; per-poolfund_walletmay differ). Authorization SoT is always on-chain (msg.sender == pool.fund_wallet, contract-enforced); the table is the pre-sign guard / display / audit set. - Server-key steps stay server-key: after the FM's
depositYield, the ORACLEsettleYieldruns on the Aset Service Key (POST /yield-distributions/{id}/distribute); after the FM'sfundRedemption, the indexer settles the payout (RedemptionCompleted→complete_redemption_atomic). The redemption approval gate (above) is unchanged.
RESERVE_FUNDER_ROLE is granted by the DEPLOY SEQUENCE, not by initialize (D4-a)
The reserve leg of every deposit leaves the pool for reserve_wallet, and that wallet is what meets instant redemption shortfalls — fundRedemption accepts it in addition to the partner's YIELD_DEPOSITOR_ROLE. Neither role opens the other's function set, which is why the reserve wallet does not simply receive YIELD_DEPOSITOR_ROLE: that would also open depositYield, and it would put a second permanent holder on a role executeFundWalletChange moves by revoking one address and granting another.
Two consequences follow from EIP-170 rather than from design, and both are operational:
- The grant is in
buildOperationalRoleCalls, not ininitialize. Granting it on chain measured +200 B against a 345 B margin. A pool created without that call comes up looking healthy and its first instant exit rests inPENDING_RESERVEwith no account able to settle it. - The role does NOT follow
executeReserveWalletChange. Re-pointing it on chain measured +333 B. After a reserve-wallet change the new wallet cannot fund and the old one still can, until an admin moves the grant. That step belongs in the wallet-change runbook. Nothing reverts at execute time; it surfaces at the next exit.
This does not reopen v3-53. That rule keeps the platform key off the partner's money-IN roles; the grantee here is the platform's own reserve wallet, standing in for nobody.
- ✅ The dev shim is gone (2026-08-13). Pool creation used to grant the platform signer a second copy of
YIELD_DEPOSITOR_ROLE(create-pool.ts), which is what let Aset sign the partner's two money-in functions — custodial. That grant is removed, and with it both server-key routes:POST /redemption-requests/{id}/fund(deleted) and theamountbranch ofPOST /pools/{id}/epoch-funding.initializestill grants the role tofund_wallet, andexecuteFundWalletChangemoves it with the wallet, so no per-fund or per-FM grant step exists or is needed. Remaining paths arerecord-fundingandepoch-funding+fund_tx_hash, both verify-and-record. ⚠️ Pools deployed before that date still carry the extra grant on-chain and must be revoked per pool. See v3-53,fm-wallet-signing-spec. :::
Permission Matrix (off-chain panel)
What each panel role may authorize. None of these require the panel user to hold a wallet — on-chain execution follows the mapping above.
Platform & Settings
| Action | 🔒 Super Admin | 👑 Admin | ⚙️ Operator | 📁 Fund Mgr |
|---|---|---|---|---|
| View dashboard | ✓ | ✓ | ✓ | Own fund (scoped, read-only) |
| View audit log (full compliance) | ✓ | ✓ | ✓ | — |
| View activity (fund-scoped, operational) | ✓ | ✓ | ✓ | Own fund (scoped, read-only) — v3-42 |
| Configure platform settings | ✓ | ✓ | — | — |
| Configure notifications | ✓ | ✓ | — | — |
| Invite / remove Admin | ✓ | — | — | — |
| Invite / remove Operator / FM | ✓ | ✓ | — | — |
| Edit admin user name — Admin (v3-47 Revised 2026-06-29: an Admin may edit-name + delete another Admin) | ✓ | ✓ | — | — |
| Edit admin user name — Operator / FM (v3-47) | ✓ | ✓ | — | — |
| Export CSV — operational lists (deposits · redemptions · yield · KYC) | ✓ | ✓ | ✓ | — |
Export CSV — audit log (/activity-events/export) | ✓ | ✓ | — | — |
Audit-log export is narrower than the other CSV exports
The row above used to read "Export CSV (all data) — ✓ ✓ ✓", which contradicted the two audit-specific statements in this same document (§Fund Manager — Panel Scoping: "activity-events export is ADMIN/SUPER_ADMIN-only", and the endpoint note in the read-scoping list) as well as 13-operations → Audit Log Retention & Export ("CSV. Admin-only (Operators cannot export)"). An Operator may export the operational list screens but not the audit log; withRole('ADMIN','SUPER_ADMIN') on activity-events.get.export.ts is the enforcement point, and the admin-web Export button is hidden for Operators to match.
Pool Management
| Action | 🔒 Super Admin | 👑 Admin | ⚙️ Operator | 📁 Fund Mgr |
|---|---|---|---|---|
| Create / deploy pool | ✓ | ✓ | — | — |
| Edit pool config (low-impact, instant) | ✓ | ✓ | ✓ (granted) | — |
Param change (fund_wallet/reserve%/KYC/lockup) → Admin cold key + timelock | ✓ | ✓ (authorize) | — | — |
| Impairment / wind-down → Admin cold key + timelock | ✓ | ✓ (authorize) | — | — |
| Pause / emergency freeze → Pauser | ✓ | ✓ (authorize) | — | — |
| View NAV & oracle status | ✓ | ✓ | ✓ | Own fund |
Deposit & LP are automatic (v3.0)
deposit() is investor-signed and atomic: USDC in → LP minted by PlatformLPToken → 10/90 reserve split, with the partner remainder leaving via an inlined safeTransfer at the tail of deposit() that emits ReleasedToPartner — all in one tx. (There is no releaseToPartner() function to call; the name is an event. → 23-money-path) There is no admin "verify LP / mint LP / process deposit / release to wallet" step (the v2.x FUND_ISSUED / PLATFORM_ISSUED / escrow-release actions are removed). Admin/Operator only monitor deposit records and configure per-pool KYC gating. See Investment Lifecycle.
Pool Updates / Announcements (WO-6)
Per-pool announcement feed (pool_updates / pool_update_revisions — see DB Schema) — a curated, manager-written feed, distinct from the system activity_events log. Full behavior in Decisions v3-57. Three categories: INFO (in-app feed only), IMPORTANT and MATERIAL_EVENT (also email holders on publish). Author label = Aset or Aset · {fund}.
| Action | 🔒 Super Admin | 👑 Admin | ⚙️ Operator | 📁 Fund Mgr |
|---|---|---|---|---|
| View updates | ✓ | ✓ | ✓ (granted) | Own fund |
| Create update (any category)³ | ✓ | ✓ | ✓ (granted) | Own fund |
| Edit update | ✓ any | ✓ any | ✓ (granted) any | Own posts only |
| Delete update (soft)⁴ | ✓ any | ✓ any | ✓ (granted) any | Own posts only |
³ Any of these roles may publish an IMPORTANT / MATERIAL_EVENT that emails all holders on creation — there is no separate admin-review gate (an FM owns disclosures for their own fund; Admin/Operator keep post-hoc edit/delete over any entry). An FM may post only on pools of their own fund; Operator create/edit/delete is opt-in via the admin-granted pools page permission (same gate as viewing), and an authorized Operator — like Admin/Super Admin — may act on any pool. Updates cannot be created on a DRAFT pool.
⁴ Delete is soft-only (sets deleted_at): the row is retained (hidden from feeds), never hard-purged — the audit trail is permanent. Editing a MATERIAL_EVENT, or recategorizing into/out of it, snapshots the prior version to pool_update_revisions before the edit lands, so material-event history cannot be rewritten silently.
Redemptions
| Action | 🔒 Super Admin | 👑 Admin | ⚙️ Operator | 📁 Fund Mgr |
|---|---|---|---|---|
| View redemption queue | ✓ | ✓ | ✓ | Own fund |
| Approve redemption (gate — no fund movement)¹ | ✓ | ✓ | — | Own fund |
Return position (close a request, LP back to investor)² | ✓ | ✓ | — | — |
¹ Approval is a non-custodial gate recorded on-chain via the Service Key (embeds the approving FM's id for audit). Payout then auto-executes via the Service Key to the original requester — the current verified LP holder (State A), whose LP is locked at request so the payee can't change (fixed destination = non-custodial; see Holder Verification). A shortfall is covered by the partner signing fundRedemption from fund_wallet (self-custody) — not by Admin/Operator/FM keys. Single-stage approve (the v2.x Operator→Admin two-stage and FM_ACCEPTED step are removed).
² Formerly "Reject redemption", and formerly available to an FM on their own fund. v3-99 removed FM access: the action's main use is closing a shortfall the fund never funded, and the FM is that fund, so leaving it with them lets the party that owes the money end the investor's exit request. Renamed because nothing is taken from the investor — the request closes and the escrowed LP returns to them. Reason category + free-text note are both mandatory. Instant pools only (epoch requests are created QUEUED, a state this action does not accept). See Return position.
Yield & Distribution
| Action | 🔒 Super Admin | 👑 Admin | ⚙️ Operator | 📁 Fund Mgr |
|---|---|---|---|---|
Trigger settleYield (accounting + fee legs) | ✓ | ✓ | ✓ (granted) | Own fund |
| Configure yield settings | ✓ | ✓ | ✓ (granted) | Own fund |
| View yield claims | ✓ | ✓ | ✓ | Own fund |
Set pool reinvest policy (allow_rollover)² | ✓ | ✓ | — | — |
² allow_rollover is a pool-level flag (Admin, instant) that enables reinvestment for the pool. Whether to reinvest is then a per-investor manual opt-in (Manual Reinvest V1 / BD5). depositYield (money in) is signed by the partner (Yield Depositor); distributeYield only marks already-deposited funds pro-rata; investors claim by signing themselves.
⚠️ Reinvest is not offered on any screen in MVP (2026-08-27, not yet reflected, v3-151) — the toggle comes off the admin forms and the CTA off the investor app, on both surfaces. The permission row stays because the field and the on-chain function stay; what closes the path is the false default, not the removed control.
Fund Management
| Action | 🔒 Super Admin | 👑 Admin | ⚙️ Operator | 📁 Fund Mgr |
|---|---|---|---|---|
| Create / delete fund · set fund status | ✓ | ✓ | — | — |
| Edit fund details | ✓ | ✓ | — | — |
| Add / remove fund members | ✓ | ✓ | — | ✓ (own fund) |
| View fund dashboard · export fund CSV | ✓ | ✓ | ✓ | Own fund |
Fund Manager — Panel Scoping
The Fund Manager uses the same admin app (no separate /fund-admin). What an FM sees is controlled by role permissions; what data they get is scoped to their own fund(s) on the server. "FM's fund(s)" = the funds mapped to the FM in fund_members; "FM's pools" = pools where pools.fund_id ∈ those fund ids.
🔐 Two enforcement layers — backend is the boundary
- Backend (authoritative): every list/detail endpoint MUST filter to the FM's fund(s) and return 403 for an out-of-scope id. This is the real security boundary. ✅ Status (implemented): the 3-role (investor / FM / admin) read-authz layer is live — GET endpoints enforce
withAuth+resolveReadScopewith FM fund-scoping and PII (email) stripping (e.g.deposits,redemption-requests,yield-distributions,dashboard,activity-events;activity-eventsexport is ADMIN/SUPER_ADMIN-only). FM read-scoping is now a real backend boundary, not frontend-only. Write endpoints were already guarded. - Frontend (presentation only): hides nav items, mutating buttons, and PII columns the FM may not use — plus route-level permission guards so a hidden page cannot be reached by typing its URL. Frontend filtering is never the boundary; it only shapes the UI.
⚠️ Today the panel hides pages via the sidebar only — routes are not guarded, so an FM could reach a hidden page by URL. Fix (decided 2026-06-12): centralized guard + shared map. Extract the route→pageKey map out of the sidebar into a shared module; both the sidebar (nav gating) and protected-layout (URL guard) consume it. The guard resolves the current path's pageKey and redirects when !hasPermission(pageKey). Param routes (/pools/:id) need prefix/segment matching, not exact equality.
FM permission keys: dashboard, deposits, redemptions, yield, pools, fund_managers (sidebar label "Funds"), and activity (v3-42 — the /audit-log route, but rendered as a fund-scoped, operational-only "Activity" view; sidebar label "Activity", no CSV export). kyc is excluded; admin-settings is admin-only. The full compliance Audit Log (cross-platform, KYC/PII, export) remains admin-only — FM is a processor, not the data controller.
Per-page spec
| Page | FM sees? | Backend data scope | Hidden / disabled for FM | PII handling |
|---|---|---|---|---|
| Dashboard | ✅ scoped | stats, action-items, activity limited to FM's pools & their investors | platform-wide totals; failures/queues outside the fund | investor names only within fund |
| Deposits | ✅ read-only | deposits where pool ∈ FM's pools | retry / bulk-retry buttons | — |
| Redemptions | ✅ read-only (no manual approve exists per v3-82; Return position is ADMIN-only per v3-99) | requests where pool ∈ FM's pools | Return position; notify-fund / escalate | hide investor email |
| Yield | ✅ | distributions where pool ∈ FM's pools | — | per-investor names only within fund |
| Pools (list) | ✅ read-only | pools where fund_id ∈ FM's funds | "Create Pool" button | — |
| Pool detail | ✅ read-only | pool must be in FM's funds, else 403 | Edit / Delete / Archive; on-chain Operations tab | investor names only within pool |
| Pool edit | ✅ limited | pool must be in FM's funds, else 403 | everything except the yield-settings fields below | — |
| Funds (list) | ✅ | funds where id ∈ fund_members(FM) | "Create Fund" button | — |
| Fund detail | ✅ | fund must be FM's, else 403; team / pools / notifications scoped | delete fund | FM/team emails (own fund only) |
| KYC | ❌ | — | entire page + route guard | — |
| Admin Settings | ❌ | — | entire page + route guard | — |
| Activity / Audit Log | ✅ as "Activity" (fund-scoped, operational only) | own pools' deposits/redemptions/yield/NAV + pool admin actions; KYC/PII & cross-platform events excluded | full compliance "Audit Log" label, CSV export, cross-fund/KYC/PII events | no investor PII surfaced (v3-42) |
📁 Group 2 decisions (2026-06-12)
- Deposits → visible, fund-scoped, read-only. FM can monitor their pools' deposits but cannot retry LP-mint / notifications (that stays Operator/Admin).
- Pool edit → yield-settings fields only. FM may edit only the yield-operations fields below, and only on their own fund's pools (subject to the normal status lock — not editable on
CLOSEDorMATURED). Archived pools are also not editable, but for a different reason on a different axis: archive isdeleted_at, not a lifecycle status, so it is not a member of that list — it withdraws the pool from the working set entirely (v3-110; Status Machines → the hidden axis). A hidden pool (is_hidden) is fully editable — hiding changes visibility only.
FM-editable fields (the only ones):
| Field | Note |
|---|---|
yield_frequency | distribution cadence (MONTHLY / QUARTERLY / CUSTOM …) — drives the next-due date / D-n countdown |
custom_interval_value + custom_interval_unit | only when yield_frequency = CUSTOM |
| next-distribution memo | the fund memo attached to the next yield distribution |
Note: there is no
yield_triggerfield — yield is claim-based / manual only (AUTO removed, v3-20).
Read-only for FM (Admin-only): name, description, asset type, category, issuer, capacity / target size, min investment, APY, lockup, accepted currencies, collateral type/ratio, reserve %, maturity, penalty config (type/rate/fee), allow_rollover (reinvest policy — Admin per matrix; ⚠️ not offered on any screen in MVP, 2026-08-27, v3-151), fund / chain, start / end dates, LP issuance model.
Rationale: this is exactly the "Configure yield settings = Own fund ✓" row of the permission matrix. Every other pool field is an economic / risk / investor-facing term and stays Admin-only (matrix: "Edit pool config" and "Set reinvest policy" are
—for FM). Changing those would alter the deal terms investors already committed to.
🔒 PII policy for FM (legal-checked 2026-06-12)
Default: FM sees investor NAME + WALLET ADDRESS; EMAIL is hidden on every FM page. The platform operator is the data controller; the FM is a processor / scoped role — confirmed to have no independent KYC/AML duty of its own — so which PII fields the FM sees is the controller's call.
- Why hide email: data minimization / need-to-know — GDPR Art. 5(1)(c) + PIPA Art. 16. Name + wallet are operationally necessary (identify investors, reconcile on-chain holdings); email is a contact field not needed for the fund-management function. Deciding a processor's visible fields is an "essential means" reserved to the controller (EDPB Guidelines 07/2020).
- Wallet address IS personal data here (KYC links it to a named person — EDPB 02/2025, Recital 26, Breyer C-582/14), but identifiability is recipient-relative (EDPS v SRB C-413/23 P): an FM with no path to the KYC mapping has lower exposure from a wallet than from a name/email.
- Exception: an FM that genuinely needs to contact an investor → route through platform-mediated messaging, never raw email.
- Enforce server-side too: omit
investor_emailfrom FM API responses, not just hide it in the UI.
⚠️ Confirm before mainnet: Korean counsel review of the PIPA application (interpretive — collection-phase principle extended to UI exposure); AML/CDD·FATCA/CRS minimum-PII requirements a fund manager must hold (not yet checked).
Backend filter reference (per endpoint, when caller role = FUND_MANAGER)
GET /pools → where fund_id IN (FM's fund_ids)
GET /pools/{id} → 403 unless pool.fund_id ∈ FM's fund_ids
GET /deposits → where pool_id IN (pools of FM's funds) [read-only]
GET /redemption-requests → where pool_id IN (pools of FM's funds)
GET /yield-distributions → where pool_id IN (pools of FM's funds)
GET /funds → where id IN (select fund_id from fund_members where lower(email) = FM email AND status = 'ACTIVE')
GET /funds/{id} → 403 unless id ∈ FM's fund_ids
GET /dashboard/stats → aggregate only over FM's pools / their investors
GET /activity-events → FM: pool-targeted events where pool ∈ FM's funds/pools
(deposits/redemptions/yield/NAV + pool admin actions).
User-targeted compliance rows (PII_ACCESS/KYC_*) excluded.
Powers the FM "Activity" view + dashboard widget (v3-42).
PUT /pools/{id} → 403 unless in FM's funds; for FM accept ONLY
{yield_frequency, custom_interval_value/unit,
next-distribution memo} — reject any other field in the payload
(KYC / admin-users → 403 for FM. /activity-events/export (CSV) → admin-only.
The full compliance Audit Log page is admin-only; FM gets the scoped Activity view.)FM's fund ids resolve from fund_members keyed by email (lower(email) = FM's admin email AND status = 'ACTIVE') — there is no admin_user_id FK; membership is a text email match (fund_members has UNIQUE(fund_id, email), no link to admin_users). An FM may map to more than one fund, so scope to the set, not a single id.
🔑 Email-keyed membership — two mandatory hardening rules (decided 2026-06-12)
Membership stays email-only (keeps the invite-by-email onboarding: a fund_members row can exist before the person has an admin_users account). Because email is the auth key, two holes MUST be closed:
- Normalize email — store
lower(trim(email))on every write tofund_membersandadmin_users(create + update), andlower()the caller email inrequireFundAccess. Prevents a casing mismatch from silently failing auth (or, worse, granting it). - Offboarding cleanup — when an
admin_usersFM is deleted/deactivated, also setfund_members.status(or delete the rows) for that email. Otherwise a recycled company email handed to a new hire who registers as FM would auto-inherit the old fund membership.
🗑️ Deleting an admin account is soft, and three tables mean three mechanisms
DELETE /admin-users/{id} is soft-only for every role — OPERATOR, ADMIN and FUND_MANAGER alike. The role changes the authority, never the mechanism: a SUPER_ADMIN is never deletable, the last remaining ADMIN is refused (409), and nobody deletes their own account.
| What is removed | How | Does the email stay reserved? |
|---|---|---|
the account (admin_users) | deleted_at set, row retained | Yes — UNIQUE (email) ignores deleted_at |
fund membership (fund_members) | status = 'INACTIVE' (no deleted_at column) | No |
a pending invitation (fund_invites) | status = 'REVOKED' | No |
Only the first reserves the address, which is why re-inviting a deleted email restores that account rather than creating a second one — clearing its page grants and credentials on the way through, and logging ADMIN_USER_RESTORE. Details in 11-db-schema → admin_users.
Deleting an FM touches the first two together (the handler deactivates memberships for that email), which is rule 2 above.
Investor Info Display
How investor identity / PII surfaces in the admin app, and who sees what. Builds on the PII policy for FM and the read-layer authz split (v3-23).
Identity model (current): one investor = one wallet + one chain. Login is keyed by (wallet_address, chain_id); a different wallet creates a new user — there is no account-linking across wallets/chains today. Multi-wallet-per-investor is a deferred product decision. So an investor's explorer link resolves to that single wallet's chain.
Surfaces: the Investor cell in Deposits / Redemptions / Yield / KYC tables shows wallet + name (name from KYC; blank until SumSub live wiring — W7).
Reveal behavior (role-differentiated):
The Investor cell in the table is plain text — not a click target. Selecting a row opens the detail panel, and the investor block sits inside it.
| Role | Investor block in the detail panel | PII call |
|---|---|---|
| Admin / Operator | collapsed "Investor" section; expanding it renders the panel | on expand → GET /users/{id}/investor-detail |
| Fund Manager | name + wallet shown inline, explorer link on the wallet | none |
🔴 The reveal triggers the PII read, not the row select. Selecting rows to scan a queue must not write an audit line per row, so the call is bound to expanding the section (routes/deposits.tsx, widgets/redemption-detail/redemption-detail-panel.tsx).
Investor panel fields by role:
| Field | Admin | Operator | FM |
|---|---|---|---|
| name, wallet | ✓ | ✓ | ✓ (inline, no panel) |
| ✓ | ✓ | ✗ | |
| country | ✓ | ✓ | — |
| KYC status | ✓ | ✓ | — |
| KYC detail / level / SumSub link | ✓ | ✗ (admin-only) | — |
| investor summary (holdings across pools, total invested, activity) | ✓ | ✓ | — |
- Server-enforced: FM responses omit
investor_email(and KYC) — frontend hiding is not the boundary (see read-authz). Email omission is the part that depends on the read-authz spec. - Explorer: chain-aware via a
chain_id → explorermap (Base8453→ Basescan, Kaia8217→ Kaiascan, Sepolia/Base-Sepolia → respective). Not hardcoded (today it is hardcoded to Basescan). - Name / country source: SumSub KYC webhook →
users.name/users.country(+ on-chain SBTcountryCode). Until SumSub live wiring (W7), these are blank — that is the root cause of today's wallet-only display.
🔒 PII-access audit (PIPA safeguard-measures standard §8)
Opening the investor panel is a PII read. Operational list endpoints return name + wallet only; the panel calls GET /users/{id}/investor-detail, which writes PII_ACCESS (actor, target investor, timestamp, fields) before returning email, country, KYC status or holdings. If the audit insert fails, the endpoint returns an error and no PII. Client-side fire-and-forget audit writes are not a security boundary.
- Retention: 2 years (KYC = sensitive + financial). The DB audit table already retains 2y; CloudWatch 48h debug logs are not the compliance record.
- Audit Log view: default recent window; date-range filter, ≤ 1 year per query; export admin-only, ≤ 1 year per file, excludes investor email. Full 2 years reachable by adjusting the range; older rows may be archived to S3.
- ⚠️ Confirm with Korean counsel (same pending PIPA review as the FM PII policy).
Auth & Route Protection
🔐 Authentication
Super Admin: Email + password (backend-created, 1 per platform). Admin / Operator / Fund Manager: Google OAuth (email matched against admin_users.email), invited via invite_code.
Panel roles need no wallet — approvals/config are off-chain. A wallet appears only as a signer: investors sign their own deposit/requestRedemption/claimYield; the partner's fund_wallet (Safe) signs depositYield/fundRedemption. The redemption payout itself is auto-executed by the Service Key to the locked requester (fixed destination). Role check on route load via GET /api/auth/role. 30-minute session timeout.
⚠️ withRole() is mandatory before mainnet. It gates the admin API so an unauthenticated JWT cannot drive the backend's Service Key (NAV, distribution, fee, approval-gate). Investor self-signing protects only the fund-movement leg — it does not replace withRole(), because NAV/yield/governance actions are not investor-signed.
⚠️ Frontend route guards are required too. The sidebar hides pages an FM/Operator may not use, but the routes themselves must also check hasPermission(pageKey) (protected-layout or per-route) — otherwise a hidden page is reachable by typing its URL. Pair this with the backend fund-scoping in Fund Manager — Panel Scoping; the backend remains the authoritative boundary.
🗄 Database Tables
admin_users — All panel roles (email, role, auth_method, password_hash, is_active, optional wallet_address for partner signers) admin_user_permissions — Operator page-level permissions fund_members — Fund Manager ↔ fund mapping (fund_id, email, wallet_address, is_primary, name, status). Membership is matched by email (an FM's admin_users.email); there is no admin_user_id FK. Multiple FMs per fund = multiple rows; the fund's on-chain wallet is a shared Safe whose signers are the FMs' individual wallets (k-of-n).
Admin Invite Flow
① Super Admin invites Admin 🔒 Super Admin
Settings → Admin Users → "Invite New User" → enter name + email, pick role Admin. The admin_users row is created immediately (role = ADMIN, auth_method = GOOGLE_OAUTH) and a best-effort invite email with a plain sign-in link is sent.
② Invited user signs in with Google 🔴 Admin
No invite link/code and no accept page — the invitee opens the admin panel and signs in with the Google account matching the invited email. /auth/admin-oauth admits them by email match against the pre-created row.
Operator Invite Flow
① Super Admin or Admin invites Operator 🔴 Admin
Same "Invite New User" modal → enter name + email, pick role Operator, and tick the page-level permissions in the same modal.
② Operator account created + granted 🟢 System
The row is created (role = OPERATOR) and the ticked permissions are written to admin_user_permissions at invite time. The Operator then signs in with Google (email match); the grants load into the session.
FM Onboarding Flow
① Admin creates Fund 🔴 Admin
Funds page → "Create Fund" → enter fund name, description, primary contact.
② Admin enters FM email 🔴 Admin
System sends invite email with invite_code.
③ FM clicks invite → Google OAuth 🟣 FM
FM signs up with Google OAuth (email must match invite). Account created with role = FUND_MANAGER. No wallet required for the panel role.
④ FM auto-linked to fund 🟢 System
FM linked via fund_members. The fund's money-moving fund_wallet (partner Safe) is configured separately and self-custodied — see Pool Models.
📖 RBAC Definition
Role-Based Access Control — access by assigned role. Aset uses two layers: four off-chain panel roles (Super Admin, Admin, Operator, Fund Manager) that authorize, and four on-chain roles (Admin (cold key), Pauser, Service Key, Yield Depositor) that execute. The binding rule is non-custodial: Aset holds no key that moves user funds.