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
19 changes: 19 additions & 0 deletions .changeset/delegated-admin-own-invitations.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@objectstack/plugin-security': patch
---

A `delegated_admin` can now read the invitations it issued (#8240)

`delegated_admin` is the one principal that may reach `/organization/invite-member`
without being an org admin (ADR-0105 D8), but #8095's narrowing of the
`sys_invitation` ledger admitted `org_owner` / `org_admin` only — and that role
normalizes to neither. It could create invitations it then could not list, with no
second path back, since better-auth's own `list-invitations` route is owner/admin
gated too.

`member_default` gains one row-scope policy, `sys_invitation_issuer`
(`inviter_id == current_user.id`, domained to the `delegated_admin` grade), a
sibling of the addressee carve-out that already sits beside it. Scope-bounded on
purpose: the issuing principal reviews **its own** issuance, not the ledger. Owner
and admin visibility is unchanged, and a plain member still reads nothing but the
invitation addressed to them.
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,14 +125,33 @@ describe('sys_invitation is row-scoped to its addressee (#8095)', () => {
// narrow it. The narrowing is the row predicate.
expect(setByName(setName).objects.sys_invitation.allowRead).toBe(true);

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

Expand DownExpand Up@@ -164,4 +183,59 @@ describe('sys_invitation is row-scoped to its addressee (#8095)', () => {
expect(admission.operation).toBe('select');
}
});

/**
* [#8240] The issuer carve-out — again a TRIPWIRE, not the proof. The proof is
* the four-persona matrix in the dogfood file, which is the only place the
* ruled option and the rejected one look different over the wire.
*
* What this block adds that the HTTP matrix cannot: the matrix is blind to a
* dropped `positions` domain (measured — mutation M3 of this card's ablation
* left all four personas green, because only owner/admin/delegated_admin can
* ever be an `inviter_id` and the first two already read everything). The
* domain exists for the principal DEMOTED out of an administrative grade, a
* state no fixture in this repo constructs. So it is pinned here, structurally.
*/
it('member_default lets a delegated_admin read the invitations THEY issued — #8240, option C', () => {
const issuer = policiesFor('member_default', 'sys_invitation').find(
(p) => p.name === 'sys_invitation_issuer',
);
expect(issuer, 'member_default must carry the #8240 issuer carve-out').toBeTruthy();

// The predicate IS the ruling. Option B — `delegated_admin` reads the whole
// ledger — was REJECTED, and its shape (`id != null`, or anything else that
// does not bind a row to this caller) passes every "the delegate can see an
// invitation" assertion just as well. Written out rather than imported so
// that editing the module cannot edit the expectation with it.
expect(issuer.using).toBe('inviter_id == current_user.id');
expect(issuer.using).toContain('current_user.id');
expect(issuer.using).not.toBe('id != null');

// Scope-bounded issuance is about the ISSUING GRADE, not about everyone who
// ever held one. Without the domain a demoted ex-admin keeps a permanent
// window onto what they issued.
expect(issuer.positions).toEqual(['delegated_admin']);
// The domain must not have silently acquired the admin identities — that is
// option B wearing this policy's name, and it would widen the ledger's
// audience exactly as the ruling refused.
expect(issuer.positions).not.toContain('org_admin');
expect(issuer.positions).not.toContain('org_owner');

// Read-only, like every other policy on this object: the write classes are
// closed at the object layer, by the ADR-0092 D2 identity write guard, and
// by `apiMethods: ['get', 'list']`.
expect(issuer.operation).toBe('select');
expect(issuer.enabled).not.toBe(false);

// Owner/admin visibility is UNCHANGED by this card — the ruling said so in
// those words. Their admission still comes from `sys_invitation_org_admin`,
// which must not have been touched to make the delegate's case work.
for (const setName of ['organization_admin', 'organization_admin_no_bypass']) {
const names = policiesFor(setName, 'sys_invitation').map((p) => p.name).sort();
expect(names, `${setName} must be untouched by #8240`).toEqual([
'sys_invitation_org',
'sys_invitation_org_admin',
]);
}
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import {
ORGANIZATION_ADMIN_NO_BYPASS,
BUILTIN_IDENTITY_ORG_ADMIN,
BUILTIN_IDENTITY_ORG_OWNER,
MEMBERSHIP_ROLE_DELEGATED_ADMIN,
} from '@objectstack/spec';
import {
MCP_AGENT_PERMISSION_SET_READ,
Expand DownExpand Up@@ -720,6 +721,68 @@ const baseDefaultPermissionSets: PermissionSet[] = [
operation: 'select',
using: 'email == current_user.email',
},
// [#8240] The ISSUER half of the same object: a `delegated_admin` reads
// the invitations THEY THEMSELVES issued. Sibling of the addressee policy
// directly above — same set, same mechanism, same `select` — differing
// only in which end of the invitation it keys on.
//
// What #8095 left behind, measured: `delegated_admin` exists precisely as
// the invitation-issuing principal that is NOT an org admin (ADR-0105 D8 /
// #3697 — better-auth grants `invitation: ["create"]` to owner/admin only,
// and under a wall-enforcing posture those two are auto-elevated to tenant
// admins, so the delegated grade is the only caller the scope-bounded
// issuance path has). The narrowing's admin admission is domained to
// `org_owner`/`org_admin`, and this role normalizes to NEITHER
// (`mapMembershipRole` maps only owner/admin/member and passes unknown
// values through verbatim); it does not receive `organization_admin` from
// the auto-grant either, whose test is `roles.includes('owner') ||
// roles.includes('admin')` over the comma-split list. So the only policy
// that reached it was the addressee scope above, and it read its own
// incoming invitations only — never the ones it had just issued. It could
// create invitations it then could not list, with no second path back
// (better-auth's own `list-invitations` route is owner/admin-gated too).
//
// Maintainer ruling (2026-08-13): option C. The issuing principal may
// confirm and review ITS OWN work; the ledger's audience is NOT widened
// beyond what #8095 ruled. Letting `delegated_admin` read the whole ledger
// (adding it to `sys_invitation_org_admin`'s domain) was considered and
// REJECTED for exactly that widening — do not "simplify" this policy into
// that one. Leaving it unable to see its own issuance was also rejected:
// an issuer that cannot confirm its own issuance is a broken shape.
//
// WARNING: the predicate IS the ruling. `inviter_id == current_user.id` is
// what makes this scope-bounded rather than an admin admission in
// disguise, and the two are indistinguishable to any pin that only asks
// whether the delegate can see A row — under `using: 'id != null'` it sees
// one too. The coverage therefore requires a ledger with MORE THAN ONE
// inviter and asserts the negative case (someone else's invitation stays
// invisible); a single-inviter fixture cannot tell the ruled option from
// the rejected one. See `invitation-ledger-row-scope.dogfood.test.ts`.
//
// Why a `positions` DOMAIN, when the predicate looks self-domaining: only
// owner/admin/delegated_admin can ever BE an `inviter_id`, and the first
// two already read the whole ledger — so today the domain changes no
// persona's result. It is here for the case that outlives today: a
// principal DEMOTED out of an administrative grade would otherwise keep a
// permanent window onto the invitations it issued while it held one, which
// is a wider grant than "a `delegated_admin` sees the invitations they
// themselves issued". The domain only ever WIDENS (same argument as
// `sys_invitation_org_admin`): a principal it does not match keeps the
// addressee scope above and fails closed, so it can never be the reason
// someone reads MORE.
//
// No twin in `viewer_readonly`, deliberately: this set carries the
// `everyone` anchor and resolves for every authenticated principal, so a
// delegate holding `viewer_readonly` gets this policy from here anyway —
// and a read-only viewer that issued an invitation is not a shape the
// system can produce.
{
name: 'sys_invitation_issuer',
object: 'sys_invitation',
operation: 'select',
using: 'inviter_id == current_user.id',
positions: [MEMBERSHIP_ROLE_DELEGATED_ADMIN],
},
],
}),
PermissionSetSchema.parse({
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,13 @@ describe('default permission sets', () => {
// `organization_id`, so unlike the two above this is not "Layer 0 is
// inert here": Layer 0 was engaged and correctly org-scoped, and the org
// is precisely the audience the row must be hidden from.
//
// [#8240] `sys_invitation_issuer` is its sibling and the ONE entry in this
// list that is not a `_self` shape: it keys on the OTHER end of the row
// (`inviter_id`), so a `delegated_admin` can review the invitations it
// issued. It sorts ahead of `_self` alphabetically; the pair is
// deliberate, not a duplicate to be collapsed.
'sys_invitation_issuer',
'sys_invitation_self',
'sys_notification_receipt_self',
'sys_oauth_access_token_self',
Expand Down
Loading
Loading