MemberJunction / MemberJunction/MJ
MJ: Entity Permissions has the same unguarded Developer/Integration grant that #4282 closed on roles
- Dominant language
- TSQL
- Stars
- 29
- Forks
- 6
- Avg merge
- 2d 1h
- Merged PRs (30d)
- 323
Description
**Type:** security · **Related:** #4260 (closed `MJ: Users`), #4282 / PR #4305 (closed `MJ: Roles` + `MJ: User Roles`)
## Why now
PR #4305's changeset names this explicitly as deliberately out of scope:
> **Not closed by this change:** `MJ: Entity Permissions` carries the same unfiltered
> `Developer`/`Integration` grant, so a holder of either role can still widen a role's permissions
> directly.
It was left out because it is an independent route with its own invariant to decide, not because
it is closed. Nothing currently tracks it, so it exists only as a sentence in a changeset that
will be consumed at the next release. This issue is that tracking.
## The gap
MJ's authority has three surfaces. Two are now guarded:
| Surface | Entity | Guard |
|---|---|---|
| Who is a superuser | `MJ: Users` (`Type`) | `MJUserEntityServer` — #4260 |
| Who holds which role | `MJ: User Roles` | `MJUserRoleEntityServer` — #4282 |
| What a role can do | **`MJ: Entity Permissions`** | **none** |
The subset rule #4282 chose is a ceiling on *distribution*: a non-Owner may only hand out
authority they already hold. That ceiling assumes the authority attached to a role is fixed.
It is not — a `Developer` who cannot grant themselves `Integration` can instead widen what
`Developer` itself is permitted to do, and arrive at the same place without ever touching a role
assignment.
## What to decide
The invariant is genuinely not obvious, which is why #4305 declined to pick one in passing:
1. **Owner-only**, like `MJ: Roles` — simplest, and breaks any non-Owner permission administration.
2. **Subset rule**, like `MJ: User Roles` — a non-Owner may only grant a permission they themselves
hold on that entity. Consistent with #4282, but "the permissions I hold" is a computed union
across every role the caller has, not a single field read, so it is not the cheap pure check
the other two guards are.
3. **Scoped** — leave it open and rely on the database-role layer. Needs evidence that layer
actually holds.
## Reproduce
Same shape as #4282's reproduction: a caller whose `Type` is `'User'` holding only `Developer`,
writing `MJ: Entity Permissions` directly. Confirm the `Developer`/`Integration` grants on that
entity first — on a baseline seed both hold unfiltered `CanCreate`/`CanUpdate`/`CanDelete`.
Contributor guide
Assessment
This issue has not been assessed yet.