Skip to content

feat(custody): let an owner's own grant narrow their access level - #4417

Merged
TaprootFreak merged 8 commits into
developfrom
fix/custody-owner-grant-narrows-level
Jul 28, 2026
Merged

feat(custody): let an owner's own grant narrow their access level#4417
TaprootFreak merged 8 commits into
developfrom
fix/custody-owner-grant-narrows-level

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

What this changes

An access level on a custody account now decides two things it did not decide before.

The owner's own grant sets their level. The owner always received WRITE, and the authorisation actually granted on the account was never consulted. An active grant an owner holds on their own account now decides it. Without such a grant nothing changes — the owner keeps full disposal, which is every account in production today.

An owner may re-level their own grant. Previously no API path led to a narrowed state at all: self-grants are refused and the owner's grant row could not be modified. An owner can now limit themselves to inspection and take the mandate back. Revoking that row stays refused — it would leave the account without an owner row and make the level unrecordable.

Rights may be taken away while an account is held, not handed out. Grant management no longer depends on account status — otherwise blocking one account would strand the grants on it, and since a narrowing blocks the owner's whole Safe, a single block would freeze everything they hold with no way back. Issuing a new grant and raising a stranger from inspection to acting are refused on a held account, though: both widen someone's authority, which is what a hold is meant to prevent. Withdrawing and lowering stay open.

Order creation now consults the level. It never did. Orders run under the custody role and reach the Safe on their own, so an owner limited to inspection could still trade; hiding buttons in a frontend would have been decoration, because the API accepted the order regardless. createOrder and confirmOrder now refuse when an own account is limited to inspection.

Why

A signed authorisation can reserve acting for one person while the account holder only inspects. Today that arrangement cannot be expressed: whoever owns the account may act, whatever the mandate says.

Design notes

  • Orders address a whole Safe, not a single account — balances and orders carry no account today. Any own account narrowed to READ therefore blocks acting: the order could touch exactly those holdings. Fail closed rather than guess which account an order belongs to. This falls away once holdings carry an account.
  • Nobody can lock themselves out. Managing grants stays tied to ownership, not to the level (requireOwner), so a narrowed owner can hand the mandate back at any time.
  • Reading is unaffected. A narrowing withdraws acting, not sight — the holdings are the owner's either way, so the multi-account refusal in resolveOwnerAccountId still rightly skips the owner.
  • Deactivated history stays out of authorisation. Only active: true grants count, unchanged.
  • Legacy accounts are untouched. The legacy path returns before any of this.

Tests

Neither service had a test suite. This adds both — 40 cases:

  • owner without a grant keeps WRITE (the regression guard)
  • owner with an active READ grant is refused WRITE, allowed READ; with WRITE keeps WRITE
  • an inactive grant never narrows anything — deactivated history must not take part
  • a READ grant on a foreign account does not block acting in one's own Safe
  • a grant on a non-active account does not count
  • re-levelling an owner's own grant works in both directions; revoking it is refused; revoking a stranger's grant works
  • non-owners are refused on both updateAccess and revokeAccess
  • the account list reports the narrowed level, filters inactive/foreign/non-active rows, and never lists an own account twice
  • the owner can lift a narrowing on a blocked account — one block must not freeze their whole Safe
  • on a blocked account, a stranger's level may be lowered but not raised, and no new grant may be issued — the refusal comes before the address is resolved, so the response cannot reveal whether a mail address is registered
  • a blocked account stays restricted — blocking must not be a way to shed a narrowing
  • order creation and confirmation refuse when acting is narrowed, pass the account holder's identity rather than either JWT id, and turn a stranger away on ownership before the narrowing check — so nobody can learn from the response whether an account is restricted

Verification

npx jest src/subdomains/core/custody --silent
  Test Suites: 3 passed, 3 total
  Tests:       45 passed, 45 total

npm run type-check      → clean
npx prettier --check    → clean

Verified against a running instance in both directions, not only in CI:

POST /custody/order  (owner limited to inspection)
  → 403 "This Safe is limited to inspection, acting is not permitted"

POST /custody/order  (mandate restored, same request)
  → 201 {"orderId":65,"status":"Created", …}

The app was started with the change in place to confirm dependency injection resolves — CustodyOrderService gained a constructor dependency, and that layer has hidden a defect from static checks before.

Until now the owner of a custody account always received WRITE, and the
authorisation actually granted on that account was never consulted. That made
one arrangement impossible to express: an authorisation in which the owner
keeps inspection only and reserves acting for someone else.

An active grant an owner holds on their own account now decides their level.
Without such a grant nothing changes — the owner keeps full disposal, which
is every account in production today.

Managing grants stays tied to ownership rather than to the level, so an owner
who narrows themselves can still hand the mandate back at any time and cannot
lock themselves out. Reading is unaffected as well: a narrowed grant withdraws
acting, not sight, and the holdings are the owner's either way.

Adds the first test suite for CustodyAccountService, covering both paths and
in particular that an inactive grant never narrows anything — deactivated
history must not take part in authorisation.
Two review findings, both real.

First, the narrowing was unreachable. Self-grants are refused and the owner's
grant row could not be modified, so no API path led to the state the previous
commit reacts to. An owner may now re-level their own grant — limiting
themselves to inspection and taking the mandate back. Revoking that row stays
refused: it would leave the account without an owner row and make the level
unrecordable.

Second, and worse: order creation never consulted the access level at all. It
runs under the custody role and reaches the Safe on its own, so an owner
limited to inspection could still trade. Hiding buttons in the frontend would
have been decoration — the API accepted the order regardless. createOrder and
confirmOrder now refuse when an own account is limited to inspection.

Orders address a whole Safe rather than a single account, since balances and
orders carry no account today. Any own account narrowed to READ therefore
blocks acting: the order could touch exactly those holdings. Fail closed
rather than guess which account an order belongs to.

Verified against a running instance in both directions: with the narrowing in
place the order is refused with 403, and with the mandate restored the very
same request succeeds. Accounts without a narrowing grant — every account in
production today — are unaffected.
…he order paths

Two review findings.

The acting check only looked at active accounts. Blocking or closing an
account would therefore have lifted the restriction — exactly when caution
matters most. Everywhere else a non-active account counts as absent and grants
nothing; here absence would have granted something, namely the right to act.
The status filter is gone and the test now insists a blocked account stays
restricted. Not exploitable today, since no code path ever sets that status,
but it would have been a trap for the first account-blocking feature.

CustodyOrderService had no test suite either, so the two new call sites rested
on manual verification alone. It now has one: both paths refuse when acting is
narrowed, both pass the right identity rather than one of the two JWT ids, and
a stranger is turned away on ownership before the narrowing check runs — so
nobody can learn from the response whether an account is restricted.

Also documents why the gap between check and write is left unlocked: only the
owner manages grants and only the owner narrows themselves, so the sole party
who could win that race is the one who may lift the restriction outright.
A narrowing blocks the owner's whole Safe and deliberately ignores account
status. Grant management, however, went through requireOwner, which demanded
an active account. Blocking a single account would therefore have stranded the
grant on it: the owner could neither lift a narrowing they had placed there nor
withdraw a stranger's access, and since one narrowing blocks every account they
hold, a single block would have frozen the whole Safe with no way back.

Grant management now depends on ownership alone. Blocking an account governs
what may be done with it, not who decides that. Missing and foreign accounts
still yield the same Forbidden, so existence stays unprobeable.

Also corrects a comment that still described the status filter removed in the
previous commit, and sorts an import.
It still claimed to be shared by checkAccess and requireOwner. Since the last
commit requireOwner resolves the account itself, without the status filter, so
that blocking an account cannot strand the grants on it. Only the data path
goes through here now.
…tive

Making grant management independent of account status went one step too far.
It was meant to keep an owner from being stranded — able to lift a narrowing
they placed on a blocked account, or withdraw a stranger's access. Issuing a
new grant is neither. Widening the circle of authorised people during a hold
is exactly what a hold is meant to prevent, and it is no way out of one.

grantAccess now refuses on a non-active account, and does so before resolving
the address, so the response cannot reveal whether a mail address is
registered. Withdrawing and re-levelling stay open.

grantAccess and getAccessList had no tests at all; both are covered now,
including that an owner can still inspect grants on a blocked account. The
blocked-account test for updateAccess used an unconditional stub that would
have stayed green if a status filter crept back into requireOwner — it now
evaluates the where clause, verified by reintroducing the filter and watching
the test turn red.
…held

Refusing new grants on a held account left the same door open one step over:
raising an existing grant from inspection to acting widens someone's authority
just as much, only through an existing row instead of a new one. It takes
effect the moment the account is released, without anyone looking again.

The rule is now uniform for a held account: rights may be taken away, not
handed out. Lowering a stranger stays open, as does anything on the owner's
own row, which is their way out of a narrowing.

Uses BadRequestException rather than ConflictException, matching six existing
places that refuse an action on an inactive or blocked resource; Conflict is
reserved for duplicates.
@github-actions

Copy link
Copy Markdown

❌ ESLint: 1 errors, 2 warnings

Removes an import left unused when the exception type changed, uses the
nullish operator the repo prefers, and records two assumptions that were only
in my head: elevation is recognised by comparing the two levels that exist, so
a third would have to turn it into an ordering comparison; and the status is
read before the write, so whatever introduces a hold must deactivate that
account's grants in the same change.

Also covers granting write on a held account, not just read — the refusal must
not depend on the level asked for.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Seven review passes were needed to reach zero findings. Each fix exposed the next edge, so most of the passes found something the previous fix had opened:

  1. The narrowing was unreachable. Self-grants are refused and the owner's grant row could not be modified, so no API path led to the state the change reacts to. Re-levelling one's own grant is now allowed; revoking it stays refused.
  2. Order creation never consulted the access level. It runs under a different role and reaches the Safe on its own, so an owner limited to inspection could still trade. Hiding buttons in a client would have been decoration.
  3. Blocking an account shed the restriction. The acting check only looked at active accounts — so a hold lifted the very limit that mattered most. The status filter is gone.
  4. …which stranded the grants on a blocked account. Grant management still required an active account, so an owner could never lift a narrowing placed there — and since a narrowing blocks their whole Safe, one block would have frozen everything they hold. Grant management now depends on ownership alone.
  5. …which let rights be handed out during a hold. Issuing a new grant, and raising a stranger from inspection to acting, both widen authority and take effect the moment the account is released. Both are refused now; withdrawing and lowering stay open.
  6. Smaller: an exception type that did not match six comparable places in the codebase, an unused import, a ternary where the codebase uses ??, and four comments that had gone stale as the code moved under them.

Beyond CI, the behaviour was verified against a running instance in both directions — with the narrowing in place an order is refused, and with the mandate restored the identical request succeeds. Accounts without a narrowing grant, which is every account in production today, are unaffected.

Neither CustodyAccountService nor CustodyOrderService had a test suite before; both do now, 40 cases.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 28, 2026 02:30
@TaprootFreak
TaprootFreak merged commit a601668 into develop Jul 28, 2026
12 checks passed
@TaprootFreak
TaprootFreak deleted the fix/custody-owner-grant-narrows-level branch July 28, 2026 05:48
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.

1 participant