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
39 changes: 39 additions & 0 deletions .changeset/18783-server-can-option-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@objectstack/objectql': minor
'@objectstack/plugin-security': minor
'@objectstack/core': minor
'@objectstack/plugin-hono-server': patch
---

feat: the server answers `current_user.can(object, verb)` in an option's `visibleWhen` (#18783)

A `select` / `multiselect` / `radio` / `checkboxes` option can gate itself on the acting subject's grants:

```ts
stage: Field.select({
label: 'Stage',
options: [
{ value: 'open', label: 'Open' },
{ value: 'escalated', label: 'Escalated', visibleWhen: "current_user.can('crm_account', 'edit')" },
],
}),
```

`@objectstack/formula` answers `can` from `EvalContext.permissions` and refuses loudly when none is passed — and until now nothing on the write path passed one. Every authenticated write that picked such an option took the evaluator's fail-open branch: the value was admitted, one `warn` said the predicate "failed to evaluate", and the gate was never enforced for anyone.

**What changes.** The write path now evaluates the predicate with the subject's effective object permissions — on `insert` (single and batch), by-id `update`, bulk `update`, and the `validate()` preview. A subject whose map withholds the verb is refused with `VALIDATION_FAILED` and a field error `invalid_option` on that field; a subject who holds it is admitted. Options whose `visibleWhen` never calls `can` are unaffected.

**Where the map comes from — one producer.**

- `@objectstack/plugin-security` implements `ISecurityService.getEffectiveObjectPermissions` (declared optional in `@objectstack/spec`) and registers the same method on the engine.
- `@objectstack/objectql` gains `registerEffectiveObjectPermissionsResolver(fn)`. The engine asks it at most ONCE per write (an N-row bulk update is one resolution), only when a picked option's predicate calls `can`, never for a write with no acting user, and never keeps the answer past the write. The answer goes through formula's `toEvalPermissions`, so a map that is not the published shape is refused rather than answered from.
- `@objectstack/core` exports `buildEffectiveObjectPermissions`: the most-permissive merge plus the super-user seed, wildcard fold, managed-write clamp and `apiOperations` annotation. `/auth/me/permissions` builds its `objects` slot with it and the new security method returns it, so the console and the server's own `can()` read the same map. The four folds (`foldWildcardSuperUser`, `clampManagedObjectWrites`, `seedSuperUserRestrictedObjects`, `annotateEffectiveApiOperations`) and the `ManagedSchemaLike` / `ApiExposureSchemaLike` types moved from `@objectstack/plugin-hono-server` to `@objectstack/core`; `@objectstack/plugin-hono-server` re-exports them under the same names, so no import changes. The `/auth/me/permissions` response is byte-identical for the same resolved sets (measured on five fixtures against the previous build).

**Failure stance.**

- If the security service cannot resolve the map, a write that needs it is refused with the resolution's own error — fail closed. It is never read as "no grants".
- With no security plugin, or an engine older than the seam, there is no permission data. The gate stays unevaluable and the value is admitted with the same `warn` as before, which names the missing input. The security plugin logs one `warn` at start when the engine lacks the seam.

**Known gap, not changed here.** `can()` reads only the per-object entries of the map, and `/auth/me/permissions` lists an object for a `'*'` wildcard grant only when that grant carries a super-user bit. So a subject whose access to an object comes only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — gets `false` from `current_user.can('<that object>', …)`, although the data plane admits the write. Before this release such a gate was never enforced for anyone; after it, that population is refused on a `can`-gated option. Any client that answers `can()` from the same `/auth/me/permissions` map gets the same `false`.

**No spec key, route or config key is added or removed.**
76 changes: 76 additions & 0 deletions packages/core/src/security/effective-object-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#18783] `buildEffectiveObjectPermissions` — the ONE function behind the
* `/auth/me/permissions` `objects` slot and `ISecurityService.getEffectiveObjectPermissions`.
*
* The four folds it composes keep their own pin batteries where they were
* written (plugin-hono-server's `fold-wildcard-superuser.test.ts` and
* `effective-api-operations.test.ts`, which exercise them through that
* package's unchanged re-exports). What is pinned HERE is the composition:
* the merge rule, the order, the guards, and that the result aliases nothing.
*/

import { describe, it, expect } from 'vitest';
import { buildEffectiveObjectPermissions } from './effective-object-permissions.js';

describe('buildEffectiveObjectPermissions', () => {
it('merges most-permissively: `true` wins, otherwise the first defined value stands', () => {
const map: any = buildEffectiveObjectPermissions([
{ objects: { deal: { allowRead: true, allowEdit: false } } },
{ objects: { deal: { allowEdit: true, allowDelete: false } } },
{ objects: { deal: { allowDelete: true, allowCreate: false } } },
{ objects: undefined },
null,
]);
expect(map.deal).toEqual({ allowRead: true, allowEdit: true, allowDelete: true, allowCreate: false });
});

it('builds FRESH entries — nothing in the map aliases an input set', () => {
const entry = { allowRead: true };
const sets = [{ objects: { deal: entry } }];
const map: any = buildEffectiveObjectPermissions(sets);
expect(map.deal).toEqual(entry);
expect(map.deal).not.toBe(entry);
map.deal.allowEdit = true;
expect(entry).toEqual({ allowRead: true });
});

it('seeds, then folds, then clamps, then annotates — in that order', () => {
const schemas: Record<string, any> = {
report: { name: 'report', enable: { apiMethods: ['get', 'list'] } },
sys_member: { name: 'sys_member', managedBy: 'better-auth' },
};
const map: any = buildEffectiveObjectPermissions(
[{ objects: { '*': { viewAllRecords: true, modifyAllRecords: true }, sys_member: { allowRead: true } } }],
{ allSchemas: () => Object.values(schemas), schemaOf: (n) => schemas[n] },
);
// Seed → fold: an entry nobody named, pulled true by the super-user bits.
expect(map.report).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true });
// Fold → clamp: the guard has the last word on a managed object's writes.
expect(map.sys_member).toMatchObject({ allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false });
// Annotate runs last, over the final entries.
expect(Array.isArray(map.report.apiOperations)).toBe(true);
});

it('a throwing schema source degrades the annotations, never the map', () => {
const warns: string[] = [];
const map: any = buildEffectiveObjectPermissions(
[{ objects: { deal: { allowRead: true } } }],
{
allSchemas: () => { throw new Error('registry down'); },
schemaOf: () => { throw new Error('registry down'); },
logger: { warn: (m) => warns.push(m) },
},
);
expect(map).toEqual({ deal: { allowRead: true } });
});

it('with no schema source at all it is the bare merge plus the fold', () => {
const map: any = buildEffectiveObjectPermissions([
{ objects: { '*': { modifyAllRecords: true }, deal: { allowRead: false } } },
]);
expect(map.deal).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true });
expect(map.deal.apiOperations).toBeUndefined();
});
});
Loading
Loading