Skip to content

fix(hono-server): apply the baseline additively on /auth/me/permissions and /me/apps (#7608) - #7615

Merged
os-help merged 1 commit into
mainfrom
claude/issue-7608-baseline-additive-on-ui-plane
Aug 11, 2026
Merged

os-help merged 1 commit into
mainfrom
claude/issue-7608-baseline-additive-on-ui-plane

Conversation

@os-help

@os-help os-help commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7608

The defect

Both permission resolutions in packages/plugins/plugin-hono-server/src/current-user-endpoints.ts applied the deployment baseline only in a second resolvePermissionSets call gated on emptiness:

:767   if (resolved.length === 0 && fallbackNames.length > 0) {    // /auth/me/permissions
:978   if (resolved.length === 0 && fallbackNames.length > 0) {    // /me/apps

That is the fallback cliff ADR-0090 D5 abolishes, verbatim:

The fallback cliff is abolished. Today's semantics ("fallback applies only while the user has zero explicit grants") mean the first real grant silently removes the user's baseline. everyone is additive like any other position: baseline ∪ explicit, always.

SecurityPlugin.resolvePermissionSetsForContext — the data plane, one function call away — has pushed the baseline into requested and resolved once for as long as D5 has existed. These two endpoints had not, so the planes disagreed the moment a member held any explicit grant.

PR #7605 (#7555) landed as 9c82146d1 and changed which names these sites resolve while leaving the guard untouched, exactly as the card predicted. Both cliffs were confirmed verbatim on main before this change.

The user-visible effect, measured

Both numbers come from the fixture shipped with this PR — a member of one org, a baseline granting two capabilities across two apps, and one explicit grant adding a third. The "before" column was measured by reverting the source change and re-running these same cases.

/me/apps before after
member with zero grants home, reports (2) home, reports (2) — unchanged
member with one explicit grant exports (1) exports, home, reports (3)
effect of receiving the first grant −1 app (2 → 1) +1 app (2 → 3)

+2 apps appear that did not before, and the two baseline apps are retained rather than traded away for the granted one.

On /auth/me/permissions, the same member regains 1 readable object, 1 readable field and 2 capabilities, and permissionSets goes from ['showcase_ops'] to ['member_default', 'showcase_ops']. The endpoint had been reporting object/field access narrower than a read actually returns.

Fail-direction was closed — the console hid what the API allowed — which is why this read as cosmetic for as long as it did.

The change

The baseline names go into requested before the single resolution, via one shared effectivePermissionSetNames helper both handlers call. That retires the second resolvePermissionSets call outright rather than merely widening its guard: once the baseline is an input to the first call, a second call over a subset of those same names can add nothing.

No principalKind === 'agent' branch, unlike the plugin's copy — a property of this surface, not an omission. D10 withholds the human baseline from an agent because its ceiling must stay exactly its scope-derived set; these endpoints are reached only through makeExecutionContextResolver, which resolves a better-auth session and never marks a principal kind. An agent has no session to present here, so the branch would be unreachable code.

The structural question the card asked

should these endpoints stop re-implementing this resolution at all and call the plugin's own service?

Measured, and deliberately not done in this PR — with the reason, rather than silently keeping the copy.

The published ISecurityService exposes resolvePermissionSetNames(context), which returns names only. Both handlers need the full resolved sets (objects, fields, systemPermissions, tabPermissions) to run their merges, so today's contract cannot serve them. De-duplicating means adding resolvePermissionSetsForContext to the contract — packages/spec/src/contracts/security-service.ts plus its exhaustive-method contract test, plugin-security's registered service literal (the method is private today), and a call site here: 3 packages, ~5 files, against this card's 1 file + 1 test.

Two things make it a net addition rather than a removal, which is what tips it:

  1. The contract requires feature-detection. ISecurityService is consumed as Partial<ISecurityService> precisely so a consumer degrades rather than assuming the full surface. So this file would have to keep the local resolution as the fallback for a SecurityPlugin too old to carry the method — leaving the copy plus the delegation plus a branch between them, until that fallback can be dropped.
  2. The two are not actually the same resolution. The plugin resolves through this.metadata / this.bootstrapPermissionSets / this.dbLoader; these endpoints pass their own loaders, and /me/apps deliberately projects a narrower column set than /auth/me/permissions does. Delegating changes which columns get loaded on both surfaces — a behaviour change well beyond a cliff fix, and one that wants its own measurement.

What this PR does instead is shrink the drift surface where it is cheap: the rule is now one named function in this file, documented against SecurityPlugin.resolvePermissionSetsForContext by name and quoting D5, rather than two inline guards that drifted independently. The remaining duplication is real and worth a follow-up card; it is not worth a 3-package contract change riding on a bug fix.

One stale cross-reference is left untouched deliberately: plugin-security's resolveFallbackPermissionSets doc-comment describes "the second step /auth/me/permissions takes … (resolved.length === 0 && fallbackName)", which this PR deletes. Correcting it is a comment-only edit in a package outside this card's file surface and currently hot from #7555 — flagged rather than folded in.

Tests

New: packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts — 12 cases, all driving the real HTTP surface and counting what a member sees, never just asserting the guard changed.

The fixtures bind the baseline set to no position and supply it via security.baselinePermissionSets, deliberately: resolveUserAuthzGrants already expands the implicit everyone position, so a fixture delivering the baseline that way would have passed before and after. The service channel is the one the cliff gated.

Mutation table — every case proven to fail, each by a named mutation:

mutation cases killed
revert — restore both resolved.length === 0 guards (pre-fix source) 8 of 12
M-A — drop the additive push from effectivePermissionSetNames 8 (incl. both zero-grant pins)
M-B — drop the !requested.includes(name) dedup 1 (baseline-held-explicitly)
M-C — treat an empty declared baseline as unset 1 (empty-baseline deployment)
M-D — /me/apps stops enforcing requiredPermissions 7 (incl. the fail-open control)

All 12 are killed by at least one mutation; the source was verified byte-identical to the fixed version after the last revert, so no mutation residue reached the commit.

The two "unchanged by this fix" pins matter: without them, deleting the baseline everywhere would pass every case that only asserts the one-grant member gained things. M-A is exactly that mutation, and it kills both.

Verification

  • pnpm lint — clean
  • pnpm typecheck — 126/126 tasks pass
  • plugin-hono-server — 199 tests pass (17 files)
  • plugin-security — 952 tests pass (46 files), the sibling plane unaffected
  • packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts — 6 pass on the served showcase stack (real boot, real sign-in, real /me/apps), the closest existing coverage of this endpoint

Changeset included. No content/docs/releases/** edits. packages/rest/src/rest-server.ts untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_015sYyt2vyi3HL63BfF6tDq9


Generated by Claude Code

Both permission resolutions in `current-user-endpoints.ts` applied the
deployment baseline only in a SECOND `resolvePermissionSets` call gated on
`resolved.length === 0` — the ADR-0090 D5 fallback CLIFF, verbatim: "the first
real grant silently removes the user's baseline".

`SecurityPlugin.resolvePermissionSetsForContext` — the DATA plane, one function
call away — has pushed the baseline into `requested` and resolved once for as
long as D5 has existed. These two endpoints had not, so the planes disagreed the
moment a member held any explicit grant.

A member who received their FIRST position or permission-set grant kept the
baseline on the data plane and lost it on the UI plane: `/auth/me/permissions`
reported object/field access narrower than a read returns, and `/me/apps`
dropped every app whose `requiredPermissions` or tab visibility came from the
baseline. Fail-direction CLOSED — the console hid what the API allowed.

Measured on the shipped fixture: receiving the first grant took a member from
2 apps to 1; it now takes them from 2 to 3, and restores one readable object,
one readable field and two capabilities on `/auth/me/permissions`.

The baseline names go into `requested` before the SINGLE resolution, via one
shared `effectivePermissionSetNames` helper both handlers call. That retires the
second call outright rather than widening its guard: once the baseline is an
input to the first call, a second call over a subset of those names can add
nothing.

Unchanged: a zero-grant member still gets the baseline; a deployment declaring
an empty baseline still resolves the caller's own names alone; `/me/apps` still
filters an app whose `requiredPermissions` nobody holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sYyt2vyi3HL63BfF6tDq9
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 8:46am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-hono-server.

5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/your-first-project.mdx (via @objectstack/plugin-hono-server)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-hono-server)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-hono-server)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-hono-server)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/plugin-hono-server)

⛔ 2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-hono-server)
  • content/docs/releases/v16.mdx (via @objectstack/plugin-hono-server)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 11, 2026
@os-help
os-help marked this pull request as ready for review August 11, 2026 09:20
@os-help
os-help added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 51fb081 Aug 11, 2026
26 checks passed
@os-help
os-help deleted the claude/issue-7608-baseline-additive-on-ui-plane branch August 11, 2026 09:36
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…SETS on the security service (objectstack-ai#7616) (objectstack-ai#7829)

`ISecurityService` could report the caller's effective permission-set NAMES
(`resolvePermissionSetNames`) and nothing else — the right primitive for an
audience check, the wrong one for a merge. A consumer folding the caller's
grants into one answer needs `objects`, `fields`, `systemPermissions` and
`tabPermissions`; none of the four is reachable from a name, so
`/auth/me/permissions` and `/me/apps` each re-implement the resolution locally
instead. One rule, three copies — drifted from the enforcement path three times
(objectstack-ai#7608, objectstack-ai#7555, objectstack-ai#6334), each divergence found only after it reached a user.

Adds `resolvePermissionSetsForContext(context)` to the published contract: the
same resolution the names surface reports the names of, returned whole and in
resolution order. Merge semantics stay with the caller, because two consumers
legitimately project different subsets of the same sets.

The method is OPTIONAL, and that is load-bearing: the contract's availability
rule has consumers resolve this service as `Partial<ISecurityService>`, so a
caller must keep its own resolution as the fallback until a floor version
carrying the method can be assumed. Optional makes that degradation a property
of the type — the unguarded call does not compile.

`plugin-security` exposes it on the REGISTERED SERVICE LITERAL, not merely as a
public class member. The consumers must never take a runtime dependency on this
plugin (optional in the stacks those endpoints serve), so the service locator is
the only seam that can carry the delegation, and a method the class declares but
the literal does not expose is unreachable across it. Pinned by a test that
resolves the service off `registerService` and calls through that handle;
reverse-verified (all 5 cases fail without the exposure).

Also closes one implementation gap so the declaration is true rather than
nominal: the `sys_permission_set` loader dropped `tab_permissions`, so every
DB-authored set came back without the column `/me/apps` filters its app list
with. Inert for enforcement (nothing on the data plane reads `tabPermissions`),
no extra query — the row is already fetched in full.

No behaviour change today: the method has no caller yet, by design. The call
sites are step 2 and land separately, because `/me/apps` deliberately projects a
narrower column set than `/auth/me/permissions` and delegating changes which
columns load on both surfaces — a user-visible change wanting its own before/
after measurement.

Also corrects a stale `resolveFallbackPermissionSets` doc-comment describing the
`resolved.length === 0 && fallbackName` step PR objectstack-ai#7615 deleted.


Claude-Session: https://claude.ai/code/session_011rK3z836HF4CjfpbJuEjaz

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: os-zhuang <jack@objectstack.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants