Skip to content

Commit 89e9808

Browse files
os-zhuangclaude
andauthored
feat(security): let a delegated_admin read the invitations it issued (#8240) (#8425)
* feat(security): let a delegated_admin read the invitations it issued (#8240) #8095 narrowed the sys_invitation ledger to owner/admin plus a row-scope carve-out for the addressee. delegated_admin -- the one grade that reaches /organization/invite-member without being an org admin (ADR-0105 D8) -- normalizes to neither org_owner nor org_admin, so it read zero of the ledger, including the invitations it had just issued itself. Maintainer ruling 2026-08-13, option C: one additional row-scope policy, sys_invitation_issuer (inviter_id == current_user.id) in member_default, domained to the delegated_admin grade. Owner/admin visibility unchanged; the ledger's audience is not widened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 * test(security): name sys_invitation_issuer in the member_default policy roster rbac-objects.test.ts enumerates member_default's RLS policy names exhaustively; the #8240 issuer carve-out has to appear there too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 * test(qa): pin the ADR-0105 D8 checklist step as non-vacuous under the issuer scope The D8 item's 'read sys_invitation as the delegated_admin and verify no row was left behind' step could not fail before this card: the delegate read zero rows unconditionally. With the issuer carve-out a leaked row from a refused attempt would carry the delegate's own inviter_id and land inside the scope, so the step is now load-bearing. Measured, not assumed -- the ruling asked for verification rather than a checklist reword, and no checklist edit was needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a911de9 commit 89e9808

5 files changed

Lines changed: 364 additions & 14 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
A `delegated_admin` can now read the invitations it issued (#8240)
6+
7+
`delegated_admin` is the one principal that may reach `/organization/invite-member`
8+
without being an org admin (ADR-0105 D8), but #8095's narrowing of the
9+
`sys_invitation` ledger admitted `org_owner` / `org_admin` only — and that role
10+
normalizes to neither. It could create invitations it then could not list, with no
11+
second path back, since better-auth's own `list-invitations` route is owner/admin
12+
gated too.
13+
14+
`member_default` gains one row-scope policy, `sys_invitation_issuer`
15+
(`inviter_id == current_user.id`, domained to the `delegated_admin` grade), a
16+
sibling of the addressee carve-out that already sits beside it. Scope-bounded on
17+
purpose: the issuing principal reviews **its own** issuance, not the ledger. Owner
18+
and admin visibility is unchanged, and a plain member still reads nothing but the
19+
invitation addressed to them.

packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,14 +125,33 @@ describe('sys_invitation is row-scoped to its addressee (#8095)', () => {
125125
// narrow it. The narrowing is the row predicate.
126126
expect(setByName(setName).objects.sys_invitation.allowRead).toBe(true);
127127

128+
// Named, not counted: `member_default` gained the #8240 issuer sibling
129+
// and `viewer_readonly` did not, so an exact-array assertion over both
130+
// would have to encode that asymmetry and would go red for the wrong
131+
// reason. What each set must carry is the addressee scope; the issuer
132+
// policy has its own block below.
128133
const scoped = policiesFor(setName, 'sys_invitation');
129-
expect(scoped.map((p) => p.name)).toEqual(['sys_invitation_self']);
130-
expect(scoped[0].using).toBe(SELF_PREDICATE);
134+
const self = scoped.find((p) => p.name === 'sys_invitation_self');
135+
expect(self, `${setName} must keep sys_invitation_self`).toBeTruthy();
136+
expect(self.using).toBe(SELF_PREDICATE);
131137
// Read the operation off THIS policy. The same-named carve-outs across
132138
// these sets do not all agree (`sys_api_key_self` is spelled three times
133139
// at two different operations), so a sibling's value is not evidence.
134-
expect(scoped[0].operation).toBe('select');
135-
expect(scoped[0].enabled).not.toBe(false);
140+
expect(self.operation).toBe('select');
141+
expect(self.enabled).not.toBe(false);
142+
// The addressee scope is UNDOMAINED, and that is load-bearing: it is the
143+
// narrowing as well as the carve-out, so a `positions` domain on it would
144+
// mean "no matching position ⇒ no policy ⇒ no row filter at all" — the
145+
// wide read #8095 is about. Only the WIDENING policies carry a domain.
146+
expect(self.positions ?? []).toEqual([]);
147+
// No other set-level policy may quietly re-open the object here. Both
148+
// sets are capped at the addressee scope plus (member_default only) the
149+
// scope-bounded issuer sibling.
150+
expect(scoped.map((p) => p.name).sort()).toEqual(
151+
setName === 'member_default'
152+
? ['sys_invitation_issuer', 'sys_invitation_self']
153+
: ['sys_invitation_self'],
154+
);
136155
},
137156
);
138157

@@ -164,4 +183,59 @@ describe('sys_invitation is row-scoped to its addressee (#8095)', () => {
164183
expect(admission.operation).toBe('select');
165184
}
166185
});
186+
187+
/**
188+
* [#8240] The issuer carve-out — again a TRIPWIRE, not the proof. The proof is
189+
* the four-persona matrix in the dogfood file, which is the only place the
190+
* ruled option and the rejected one look different over the wire.
191+
*
192+
* What this block adds that the HTTP matrix cannot: the matrix is blind to a
193+
* dropped `positions` domain (measured — mutation M3 of this card's ablation
194+
* left all four personas green, because only owner/admin/delegated_admin can
195+
* ever be an `inviter_id` and the first two already read everything). The
196+
* domain exists for the principal DEMOTED out of an administrative grade, a
197+
* state no fixture in this repo constructs. So it is pinned here, structurally.
198+
*/
199+
it('member_default lets a delegated_admin read the invitations THEY issued — #8240, option C', () => {
200+
const issuer = policiesFor('member_default', 'sys_invitation').find(
201+
(p) => p.name === 'sys_invitation_issuer',
202+
);
203+
expect(issuer, 'member_default must carry the #8240 issuer carve-out').toBeTruthy();
204+
205+
// The predicate IS the ruling. Option B — `delegated_admin` reads the whole
206+
// ledger — was REJECTED, and its shape (`id != null`, or anything else that
207+
// does not bind a row to this caller) passes every "the delegate can see an
208+
// invitation" assertion just as well. Written out rather than imported so
209+
// that editing the module cannot edit the expectation with it.
210+
expect(issuer.using).toBe('inviter_id == current_user.id');
211+
expect(issuer.using).toContain('current_user.id');
212+
expect(issuer.using).not.toBe('id != null');
213+
214+
// Scope-bounded issuance is about the ISSUING GRADE, not about everyone who
215+
// ever held one. Without the domain a demoted ex-admin keeps a permanent
216+
// window onto what they issued.
217+
expect(issuer.positions).toEqual(['delegated_admin']);
218+
// The domain must not have silently acquired the admin identities — that is
219+
// option B wearing this policy's name, and it would widen the ledger's
220+
// audience exactly as the ruling refused.
221+
expect(issuer.positions).not.toContain('org_admin');
222+
expect(issuer.positions).not.toContain('org_owner');
223+
224+
// Read-only, like every other policy on this object: the write classes are
225+
// closed at the object layer, by the ADR-0092 D2 identity write guard, and
226+
// by `apiMethods: ['get', 'list']`.
227+
expect(issuer.operation).toBe('select');
228+
expect(issuer.enabled).not.toBe(false);
229+
230+
// Owner/admin visibility is UNCHANGED by this card — the ruling said so in
231+
// those words. Their admission still comes from `sys_invitation_org_admin`,
232+
// which must not have been touched to make the delegate's case work.
233+
for (const setName of ['organization_admin', 'organization_admin_no_bypass']) {
234+
const names = policiesFor(setName, 'sys_invitation').map((p) => p.name).sort();
235+
expect(names, `${setName} must be untouched by #8240`).toEqual([
236+
'sys_invitation_org',
237+
'sys_invitation_org_admin',
238+
]);
239+
}
240+
});
167241
});

packages/plugins/plugin-security/src/objects/default-permission-sets.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ORGANIZATION_ADMIN_NO_BYPASS,
77
BUILTIN_IDENTITY_ORG_ADMIN,
88
BUILTIN_IDENTITY_ORG_OWNER,
9+
MEMBERSHIP_ROLE_DELEGATED_ADMIN,
910
} from '@objectstack/spec';
1011
import {
1112
MCP_AGENT_PERMISSION_SET_READ,
@@ -720,6 +721,68 @@ const baseDefaultPermissionSets: PermissionSet[] = [
720721
operation: 'select',
721722
using: 'email == current_user.email',
722723
},
724+
// [#8240] The ISSUER half of the same object: a `delegated_admin` reads
725+
// the invitations THEY THEMSELVES issued. Sibling of the addressee policy
726+
// directly above — same set, same mechanism, same `select` — differing
727+
// only in which end of the invitation it keys on.
728+
//
729+
// What #8095 left behind, measured: `delegated_admin` exists precisely as
730+
// the invitation-issuing principal that is NOT an org admin (ADR-0105 D8 /
731+
// #3697 — better-auth grants `invitation: ["create"]` to owner/admin only,
732+
// and under a wall-enforcing posture those two are auto-elevated to tenant
733+
// admins, so the delegated grade is the only caller the scope-bounded
734+
// issuance path has). The narrowing's admin admission is domained to
735+
// `org_owner`/`org_admin`, and this role normalizes to NEITHER
736+
// (`mapMembershipRole` maps only owner/admin/member and passes unknown
737+
// values through verbatim); it does not receive `organization_admin` from
738+
// the auto-grant either, whose test is `roles.includes('owner') ||
739+
// roles.includes('admin')` over the comma-split list. So the only policy
740+
// that reached it was the addressee scope above, and it read its own
741+
// incoming invitations only — never the ones it had just issued. It could
742+
// create invitations it then could not list, with no second path back
743+
// (better-auth's own `list-invitations` route is owner/admin-gated too).
744+
//
745+
// Maintainer ruling (2026-08-13): option C. The issuing principal may
746+
// confirm and review ITS OWN work; the ledger's audience is NOT widened
747+
// beyond what #8095 ruled. Letting `delegated_admin` read the whole ledger
748+
// (adding it to `sys_invitation_org_admin`'s domain) was considered and
749+
// REJECTED for exactly that widening — do not "simplify" this policy into
750+
// that one. Leaving it unable to see its own issuance was also rejected:
751+
// an issuer that cannot confirm its own issuance is a broken shape.
752+
//
753+
// WARNING: the predicate IS the ruling. `inviter_id == current_user.id` is
754+
// what makes this scope-bounded rather than an admin admission in
755+
// disguise, and the two are indistinguishable to any pin that only asks
756+
// whether the delegate can see A row — under `using: 'id != null'` it sees
757+
// one too. The coverage therefore requires a ledger with MORE THAN ONE
758+
// inviter and asserts the negative case (someone else's invitation stays
759+
// invisible); a single-inviter fixture cannot tell the ruled option from
760+
// the rejected one. See `invitation-ledger-row-scope.dogfood.test.ts`.
761+
//
762+
// Why a `positions` DOMAIN, when the predicate looks self-domaining: only
763+
// owner/admin/delegated_admin can ever BE an `inviter_id`, and the first
764+
// two already read the whole ledger — so today the domain changes no
765+
// persona's result. It is here for the case that outlives today: a
766+
// principal DEMOTED out of an administrative grade would otherwise keep a
767+
// permanent window onto the invitations it issued while it held one, which
768+
// is a wider grant than "a `delegated_admin` sees the invitations they
769+
// themselves issued". The domain only ever WIDENS (same argument as
770+
// `sys_invitation_org_admin`): a principal it does not match keeps the
771+
// addressee scope above and fails closed, so it can never be the reason
772+
// someone reads MORE.
773+
//
774+
// No twin in `viewer_readonly`, deliberately: this set carries the
775+
// `everyone` anchor and resolves for every authenticated principal, so a
776+
// delegate holding `viewer_readonly` gets this policy from here anyway —
777+
// and a read-only viewer that issued an invitation is not a shape the
778+
// system can produce.
779+
{
780+
name: 'sys_invitation_issuer',
781+
object: 'sys_invitation',
782+
operation: 'select',
783+
using: 'inviter_id == current_user.id',
784+
positions: [MEMBERSHIP_ROLE_DELEGATED_ADMIN],
785+
},
723786
],
724787
}),
725788
PermissionSetSchema.parse({

packages/plugins/plugin-security/src/objects/rbac-objects.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@ describe('default permission sets', () => {
112112
// `organization_id`, so unlike the two above this is not "Layer 0 is
113113
// inert here": Layer 0 was engaged and correctly org-scoped, and the org
114114
// is precisely the audience the row must be hidden from.
115+
//
116+
// [#8240] `sys_invitation_issuer` is its sibling and the ONE entry in this
117+
// list that is not a `_self` shape: it keys on the OTHER end of the row
118+
// (`inviter_id`), so a `delegated_admin` can review the invitations it
119+
// issued. It sorts ahead of `_self` alphabetically; the pair is
120+
// deliberate, not a duplicate to be collapsed.
121+
'sys_invitation_issuer',
115122
'sys_invitation_self',
116123
'sys_notification_receipt_self',
117124
'sys_oauth_access_token_self',

0 commit comments

Comments
 (0)