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
41 changes: 41 additions & 0 deletions .changeset/hono-current-user-position-grants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@objectstack/plugin-hono-server": patch
---

fix(plugin-hono-server): `/auth/me/permissions` resolves position-bound grants through the canonical resolver (#6334)

On a hono host, `/api/v1/auth/me/permissions` and `/me/apps` resolved the caller
through a standalone resolver in `current-user-endpoints.ts` that read
`sys_member` + `sys_user_permission_set` — and **nothing else**. It never read
`sys_user_position` / `sys_position_permission_set`, so a permission set bound to
a **position** — the ADR-0090 D3 distribution mechanism, and how the showcase app
grants every persona — was invisible to these endpoints: the response carried
`positions: []`, omitted the set from `permissionSets`, and withheld its
`systemPermissions`.

That is the surface objectui's four `useCapabilityGate` gates read (toolbar, row
kebab, record header, bulk bar — ADR-0066 D4), while the data plane resolves
through SecurityPlugin's middleware on the canonical chain. So the server
**granted** the action and the UI **hid the button** from a user who genuinely
held the capability — the failure direction the fail-open design names as the
worse one.

A second, quieter half of the same divergence: the hand-rolled envelope published
membership roles under `roles`, while `ExecutionContext` — and every reader in
that file — calls the field `positions` (ADR-0090 D3, "formerly `roles`"). The
endpoint's `positions` was therefore always `[]` and those names never reached
`resolvePermissionSets` either, independently of the position tables.

The session lookup (the genuinely transport-specific part) stays where it is; all
grant aggregation now delegates to `resolveUserAuthzGrants`, the canonical
resolver's userId-driven core, which `@objectstack/core` exports for exactly this
caller shape — a surface that already knows who the principal is and needs the
same envelope with no HTTP request to resolve it from. Arriving with it, none of
it re-implemented: `sys_user_position` (null org = global, active-org match,
ADR-0091 validity windows), the implicit `everyone` audience anchor (ADR-0090 D5),
`sys_position_permission_set`, `mapMembershipRole` normalization, the
platform-admin derivation and posture rung, and the `ai_seat` synthesis.

No response-envelope change: `positions` / `permissionSets` / `systemPermissions`
/ `tabPermissions` keep their names and shapes, and now carry the grants the
server was already enforcing.
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #6334 — position-bound capabilities must reach `/auth/me/permissions`.
//
// The standalone resolver behind these endpoints used to read `sys_member` +
// `sys_user_permission_set` and nothing else. `sys_user_position` /
// `sys_position_permission_set` — the ADR-0090 D3 DISTRIBUTION mechanism, which
// is how showcase grants every persona — were invisible to it, so a permission
// set bound to a position never reached the response: `positions: []`,
// `permissionSets` without the set, `systemPermissions` without its
// capabilities. objectui's four `useCapabilityGate` surfaces (ADR-0066 D4) read
// this endpoint, so the button was hidden from a user who genuinely HELD the
// capability, while the data plane (SecurityPlugin middleware, canonical chain)
// granted the same action. Hiding from an entitled user is the failure
// direction the fail-open design names as the worse one.
//
// The fix delegates all grant aggregation to `resolveUserAuthzGrants` — the
// canonical resolver's userId-driven core. These cases therefore assert BOTH
// halves: the issue's own repro (a position-bound set surfaces), and the
// semantics that come free with the delegation and would have to be re-written
// by hand otherwise — the implicit `everyone` anchor (ADR-0090 D5), null-org =
// global, active-org matching, and the ADR-0091 validity window.
//
// Every negative case here carries a co-present VALID grant and asserts it
// surfaced. A negative asserted on its own would pass in the pre-fix world for
// the wrong reason — because the resolver produced nothing at all, not because
// it judged the invalid row correctly.

import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { registerCurrentUserEndpoints } from './current-user-endpoints';

const ME_PERMISSIONS = '/api/v1/auth/me/permissions';
const ME_APPS = '/api/v1/me/apps';

const USER = 'usr_ops';
const ACTIVE_ORG = 'org_active';
const OTHER_ORG = 'org_other';

/** Far past / far future bounds for the ADR-0091 window cases (real clock). */
const PAST = '2020-01-01T00:00:00.000Z';
const FUTURE = '2999-01-01T00:00:00.000Z';

type Row = Record<string, any>;

/** `where` matcher: scalar equality plus the `$in` form both resolvers use. */
function matches(row: Row, where: Row | undefined): boolean {
return Object.entries(where ?? {}).every(([key, cond]) => {
const value = row[key] ?? null;
if (cond && typeof cond === 'object' && Array.isArray((cond as any).$in)) {
return (cond as any).$in.includes(value);
}
return value === (cond ?? null);
});
}

/**
* A seeded fake data engine — READ ONLY (`find`), which is every verb this
* surface uses. `where`/`limit` are honoured so a resolver that queries the
* wrong table or the wrong scope gets nothing, exactly as it would in the
* engine.
*/
function makeQl(tables: Record<string, Row[]>) {
return {
find: async (object: string, opts: any, _ctx?: any) => {
const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where));
return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;
},
registry: { getAllApps: () => tables.__apps ?? [], getAllObjects: () => [] },
getSchema: () => undefined,
};
}

/** A permission set as `sys_permission_set` stores it (JSON columns as text). */
function permissionSet(id: string, name: string, systemPermissions: string[]): Row {
return {
id,
name,
object_permissions: '{}',
field_permissions: '{}',
system_permissions: JSON.stringify(systemPermissions),
tab_permissions: '{}',
};
}

/**
* A stand-in for plugin-security's `PermissionEvaluator` on its DB-backed
* branch: resolve the requested identifiers through the loader the endpoint
* supplies. plugin-hono-server must not depend on plugin-security (that
* package is OPTIONAL in the stacks these endpoints serve), so the double
* covers the one method both handlers call — and it is the DB branch that
* matters here, since the identifiers under test are exactly what the endpoint
* feeds it.
*/
const evaluator = {
resolvePermissionSets: async (
identifiers: string[],
_metadata: unknown,
_bootstrap: unknown[] | undefined,
dbLoader?: (names: string[]) => Promise<unknown[]>,
) => (dbLoader ? dbLoader(identifiers) : []),
};

/** Minimal `metadata` — present so the endpoint takes its full (non-degraded) branch. */
const metadata = { list: async () => [] as unknown[] };

interface MountOptions {
tables: Record<string, Row[]>;
/** Active organization on the session (`session.activeOrganizationId`). */
activeOrg?: string | null;
}

function mount({ tables, activeOrg = ACTIVE_ORG }: MountOptions) {
const services: Record<string, unknown> = {
auth: {
api: {
getSession: async () => ({
user: { id: USER, email: 'ops@example.com' },
session: activeOrg ? { activeOrganizationId: activeOrg } : {},
}),
},
},
objectql: makeQl(tables),
metadata,
'security.permissions': evaluator,
};
const app = new Hono();
registerCurrentUserEndpoints({
rawApp: app,
ctx: {
logger: { debug() {}, warn() {} },
// Throws for an unclaimed slot, like the real kernel locator.
getService: <T,>(name: string): T => {
if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
return services[name] as T;
},
},
});
return app;
}

const permissionsOf = async (app: any) =>
(await app.request(`http://localhost${ME_PERMISSIONS}`)).json() as Promise<any>;

/** The showcase-shaped seed: `ops` position ↔ `showcase_ops` permission set. */
function baseTables(overrides: Record<string, Row[]> = {}): Record<string, Row[]> {
return {
sys_user: [{ id: USER, email: 'ops@example.com' }],
sys_member: [{ user_id: USER, organization_id: ACTIVE_ORG, role: 'member' }],
sys_user_position: [],
sys_user_permission_set: [],
sys_position: [
{ id: 'pos_ops', name: 'ops' },
{ id: 'pos_auditor', name: 'auditor' },
{ id: 'pos_everyone', name: 'everyone' },
],
sys_position_permission_set: [
{ position_id: 'pos_ops', permission_set_id: 'ps_ops' },
{ position_id: 'pos_auditor', permission_set_id: 'ps_auditor' },
{ position_id: 'pos_everyone', permission_set_id: 'ps_default' },
],
sys_permission_set: [
permissionSet('ps_ops', 'showcase_ops', ['setup.access', 'showcase.export_data']),
permissionSet('ps_auditor', 'showcase_auditor', ['showcase.audit_read']),
permissionSet('ps_default', 'showcase_member_default', []),
permissionSet('ps_direct', 'showcase_direct', ['showcase.direct_only']),
],
...overrides,
};
}

describe('position-bound permission sets reach /auth/me/permissions (#6334)', () => {
it('surfaces a sys_user_position → sys_position_permission_set grant', async () => {
// The issue's repro, verbatim: one `sys_user_position` row for the
// active org, `valid_from`/`valid_until` both empty.
const app = mount({
tables: baseTables({
sys_user_position: [{ id: 'up1', user_id: USER, position: 'ops', organization_id: ACTIVE_ORG }],
}),
});

const body = await permissionsOf(app);

expect(body.authenticated).toBe(true);
expect(body.positions).toContain('ops');
expect(body.permissionSets).toContain('showcase_ops');
expect(body.systemPermissions).toContain('showcase.export_data');
expect(body.systemPermissions).toContain('setup.access');
});

it('still resolves a direct sys_user_permission_set binding (positive control)', async () => {
// The table the pre-fix resolver DID read — the issue's own control,
// where switching to a direct binding made the capability appear. It
// must keep working after the delegation.
const app = mount({
tables: baseTables({
sys_user_permission_set: [
{ id: 'ups1', user_id: USER, permission_set_id: 'ps_direct', organization_id: null },
],
}),
});

const body = await permissionsOf(app);

expect(body.permissionSets).toContain('showcase_direct');
expect(body.systemPermissions).toContain('showcase.direct_only');
});

it('carries the implicit `everyone` position and its default set (ADR-0090 D5)', async () => {
// No position row at all: every AUTHENTICATED member implicitly holds
// `everyone`, so sets bound to it resolve like any other position-bound
// grant. The issue saw this missing too (`everyone → showcase_member_default`).
const app = mount({ tables: baseTables() });

const body = await permissionsOf(app);

expect(body.positions).toContain('everyone');
expect(body.permissionSets).toContain('showcase_member_default');
});

it('projects the normalized org-membership position (sys_member.role)', async () => {
// `member` → `org_member` (mapMembershipRole). The pre-fix envelope put
// these under `roles` while every reader here — and ExecutionContext
// itself — calls the field `positions`, so they were dropped on the
// floor independently of the position tables.
const body = await permissionsOf(mount({ tables: baseTables() }));

expect(body.positions).toContain('org_member');
});

it('treats a null-org position row as global (resolves under any active org)', async () => {
const app = mount({
tables: baseTables({
sys_user_position: [{ id: 'up1', user_id: USER, position: 'ops', organization_id: null }],
}),
});

const body = await permissionsOf(app);

expect(body.positions).toContain('ops');
expect(body.systemPermissions).toContain('showcase.export_data');
});

it('drops a position row scoped to another organization, keeping the active-org one', async () => {
const app = mount({
tables: baseTables({
sys_user_position: [
{ id: 'up1', user_id: USER, position: 'ops', organization_id: ACTIVE_ORG },
{ id: 'up2', user_id: USER, position: 'auditor', organization_id: OTHER_ORG },
],
}),
});

const body = await permissionsOf(app);

// The co-present valid grant is asserted so the negative below cannot
// pass merely because nothing resolved.
expect(body.positions).toContain('ops');
expect(body.positions).not.toContain('auditor');
expect(body.permissionSets).not.toContain('showcase_auditor');
expect(body.systemPermissions).not.toContain('showcase.audit_read');
});

it('drops position grants outside their ADR-0091 validity window', async () => {
const app = mount({
tables: baseTables({
sys_user_position: [
{ id: 'up1', user_id: USER, position: 'ops', organization_id: ACTIVE_ORG },
// Expired, and not-yet-active — both spellings of "outside
// the half-open [from, until) window".
{
id: 'up2', user_id: USER, position: 'auditor',
organization_id: ACTIVE_ORG, valid_until: PAST,
},
{
id: 'up3', user_id: USER, position: 'auditor',
organization_id: ACTIVE_ORG, valid_from: FUTURE,
},
],
}),
});

const body = await permissionsOf(app);

expect(body.positions).toContain('ops');
expect(body.positions).not.toContain('auditor');
expect(body.systemPermissions).not.toContain('showcase.audit_read');
});
});

describe('/me/apps sees the same position-bound capabilities (#6334)', () => {
it('lists an app whose requiredPermissions come from a position-bound set', async () => {
const app = mount({
tables: baseTables({
sys_user_position: [{ id: 'up1', user_id: USER, position: 'ops', organization_id: ACTIVE_ORG }],
__apps: [
{ name: 'exports', requiredPermissions: ['showcase.export_data'] },
{ name: 'billing', requiredPermissions: ['billing.manage'] },
],
}),
});

const body = await (await app.request(`http://localhost${ME_APPS}`)).json() as any;

// `exports` is entered through a capability the user holds ONLY via the
// position chain; `billing` is the control that the filter still filters.
expect(body.apps.map((a: any) => a.name)).toEqual(['exports']);
});
});
Loading
Loading