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
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@objectstack/spec': minor
---

**`ISecurityService` gains the effective-object-permission reader.**

`getEffectiveObjectPermissions(context?)` answers the server-resolved effective object-permission
map for a caller — object name -> `EffectiveObjectPermission` — which is the `objects` slot of the
published `/auth/me/permissions` response (`GetEffectivePermissionsResponseSchema`): the caller's
permission sets merged most-permissively, the super-user folds applied, each entry annotated with
its effective API-operation set. Purely additive: the member is OPTIONAL, nothing is renamed,
narrowed or removed, and a security service that omits it still satisfies the contract.

Clause-②: yes (widening)

**Why it is a reader on the service rather than a merge each consumer does.** The existing
`resolvePermissionSetsForContext` deliberately leaves the merge to the caller, because two consumers
legitimately project *different* subsets of the same sets. This map is the projection two consumers
need to be *identical*: the effective map `/auth/me/permissions` serves is also the map the
permission predicate `current_user.can(object, verb)` reads through `EvalContext.permissions`, and
that consumer cannot tell a wrong map from a right one. `@objectstack/formula` already states the
hazard at its own door — a hand-built permission map "has no shape of its own to be wrong against:
it parses, `can()` answers from it, and the answer is a confident silent denial". One producer
removes the second copy before it is written.

**Three properties of the contract, each load-bearing:**

- **The WHOLE map, with no object parameter.** An entry the map omits reads as "no grant" and
answers `false`, which is indistinguishable from a measured denial — so a caller may not narrow
the map to the objects it expects to be asked about. A predicate names its objects in its own
source; the site assembling the context does not know them.
- **It THROWS on resolution failure and never degrades to `{}`.** An empty map is a *real* answer
here (this subject holds nothing), so a failure returning it would publish a denial of everything
as a measured fact. Callers fail closed on the throw, exactly as they must for
`resolvePermissionSetNames` and `resolvePermissionSetsForContext`.
- **Absence is a defined state.** Consumers feature-detect
(`typeof svc.getEffectiveObjectPermissions === 'function'`), and the fallback is NOT an empty map
and NOT a locally merged one: a caller that cannot get this answer passes no permission data at
all, leaving a permission-gated predicate loudly unevaluable instead of quietly denied.

Request-scoped: resolve it once per request, never per evaluation (the map is pinned data an
evaluator re-reads for free) and never cached across requests (a grant may since have been revoked).

**This is the declaration only.** The engine-side threading — populating `EvalContext.permissions`
from this reader at each `current_user`-bound predicate evaluation site, and the
`packages/objectql` / `plugin-security` wiring — lands separately under the same maintainer ruling,
which orders a census of those sites first.
108 changes: 108 additions & 0 deletions packages/spec/src/contracts/security-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,114 @@ describe('Security Service Contract', () => {
expect(sets).toHaveLength(2);
});

it('getEffectiveObjectPermissions is OPTIONAL — absence is NOT an empty map (compile-time)', () => {
// THE structural pin behind "a caller that cannot get this answer passes NO
// permission data at all". Optional is what makes that a property of the
// TYPE: a security service that predates the method still satisfies the
// contract, and the unguarded call does not compile, so the absent branch
// cannot be dropped on the way to a map whose omissions read as denials.
const withoutIt: ISecurityService = makeService();
expect(typeof withoutIt.getEffectiveObjectPermissions).toBe('undefined');

// Never invoked — its only job is to make the COMPILER prove the point.
const mustNotCompileWithoutAGuard = () =>
// @ts-expect-error possibly undefined — a consumer must feature-detect first
withoutIt.getEffectiveObjectPermissions({ userId: 'u1' });
expect(typeof mustNotCompileWithoutAGuard).toBe('function');

// ⛔ And the fallback an absent method leaves is NOT `{}`. The guarded call
// degrades to `undefined`, which a caller must carry as "no permission data"
// — distinguishable from the empty map below, which is a real answer.
expect(withoutIt.getEffectiveObjectPermissions?.({ userId: 'u1' })).toBeUndefined();

// The sets-level sibling is a different question, not an older spelling of
// this one, so a service carrying only it is a complete implementation.
expect(typeof withoutIt.resolvePermissionSetNames).toBe('function');
});

it('an EMPTY map is a real answer and an ABSENT entry denies — which is why the map is never narrowed', async () => {
// The two empty answers this surface distinguishes are ABSENCE OF THE
// METHOD (above) and an empty MAP (here) — not `undefined` versus `[]` as on
// the field-projection pair. An empty map says this subject holds nothing,
// and every object read out of it answers "no grant".
const holdsNothing = makeService({ getEffectiveObjectPermissions: async () => ({}) });
await expect(holdsNothing.getEffectiveObjectPermissions?.({ userId: 'u1' })).resolves.toEqual({});

// An entry the map omits is read the same way as an all-`false` entry: no
// grant. That is exactly why a caller may not narrow the map to the objects
// it expects to be asked about — every object left out silently denies,
// indistinguishable from a measured refusal.
const map = await makeService({
getEffectiveObjectPermissions: async () => ({
deal: { allowRead: true, allowEdit: true },
contact: { allowRead: false },
}),
}).getEffectiveObjectPermissions?.({ userId: 'u1' });
expect(map?.deal?.allowRead).toBe(true);
expect(map?.contact?.allowRead).toBe(false);
// `lead` was never mentioned by the subject's resolution…
expect(map && 'lead' in map).toBe(false);
// …and a reader of that absence cannot tell it from `contact`'s measured
// `false`. The contract therefore requires the WHOLE map, not a slice.
expect(map?.lead).toBeUndefined();
});

it('a resolution failure THROWS — it never degrades to an empty map', async () => {
// Same failure stance as resolvePermissionSetNames / resolvePermissionSetsForContext,
// and for a sharper reason: `{}` is a REAL answer on this surface, so a
// failure that returned it would publish "this subject holds nothing" as a
// measured fact. Callers fail CLOSED on the throw; they may not read it as
// "no grants".
const failing = makeService({
getEffectiveObjectPermissions: async () => {
throw new Error('permission-set resolution failed');
},
});
await expect(failing.getEffectiveObjectPermissions?.({ userId: 'u1' })).rejects.toThrow(
/resolution failed/,
);
});

it('the map is the server-resolved effective entry, not one permission set’s authored block', async () => {
// The shape is the published response-side entry (`EffectiveObjectPermission`,
// the `objects` slot of /auth/me/permissions): the caller's sets already
// merged most-permissively, with `apiOperations` annotated. A consumer that
// received one SET's authored `objects` block instead would get a value that
// parses and answers confidently about the wrong thing.
const service = makeService({
resolvePermissionSetsForContext: async () => [
{ name: 'member_default', objects: { deal: { allowRead: true } } },
{ name: 'sales_manager', objects: { deal: { allowEdit: true } } },
] as any,
getEffectiveObjectPermissions: async () => ({
deal: { allowRead: true, allowEdit: true, apiOperations: ['get', 'list'] },
}),
});

const sets = await service.resolvePermissionSetsForContext?.({ userId: 'u1' });
const effective = await service.getEffectiveObjectPermissions?.({ userId: 'u1' });

// Neither set's own block carries both bits…
expect(sets?.[0]?.objects?.deal).toEqual({ allowRead: true });
expect(sets?.[1]?.objects?.deal).toEqual({ allowEdit: true });
// …and the merge is what this method answers, annotated with the effective
// operation set that exists only on the response surface.
expect(effective?.deal).toEqual({ allowRead: true, allowEdit: true, apiOperations: ['get', 'list'] });
});

it('a system context bypasses on this plane too', async () => {
const service = makeService({
getEffectiveObjectPermissions: async (context) =>
context?.isSystem
? { deal: { allowRead: true, allowEdit: true, allowDelete: true } }
: { deal: { allowRead: true } },
});
await expect(service.getEffectiveObjectPermissions?.({ isSystem: true }))
.resolves.toEqual({ deal: { allowRead: true, allowEdit: true, allowDelete: true } });
await expect(service.getEffectiveObjectPermissions?.({ userId: 'u1' }))
.resolves.toEqual({ deal: { allowRead: true } });
});

it('a partial implementation is feature-detectable rather than wrong', () => {
// Consumers probe (`typeof svc.getReadableFields === 'function'`) so an
// implementation may omit a method it cannot honour and still be usable.
Expand Down
62 changes: 61 additions & 1 deletion packages/spec/src/contracts/security-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
import type { FilterCondition } from '../data/filter.zod.js';
import type { ExecutionContext } from '../kernel/execution-context.zod.js';
import type { ExplainDecision, ExplainOperation } from '../security/explain.zod.js';
import type { ObjectAccessScope, PermissionSet } from '../security/permission.zod.js';
import type { EffectiveObjectPermission, ObjectAccessScope, PermissionSet } from '../security/permission.zod.js';

/**
* The context shape these methods accept.
Expand Down Expand Up @@ -397,6 +397,66 @@ export interface ISecurityService {
*/
resolvePermissionSetsForContext?(context?: SecurityContext): Promise<PermissionSet[]>;

/**
* [maintainer ruling 2026-09-18, decision batch 156 item 5 letter A] The
* EFFECTIVE object-permission map for `context` — object name ->
* `EffectiveObjectPermission`, server-resolved: the caller's permission sets
* merged most-permissively, the super-user folds applied, and each entry's
* effective API-operation set annotated. Byte-for-byte the `objects` slot of
* the published `/auth/me/permissions` response
* (`GetEffectivePermissionsResponseSchema`), because it is the same answer
* computed once rather than twice.
*
* **Why the map, when {@link resolvePermissionSetsForContext} already hands
* over the sets.** That method's note — the merge stays with the CALLER,
* because two consumers legitimately project different subsets — is about
* projections that DIFFER. This one is the projection two consumers need to
* be IDENTICAL: the effective map `/auth/me/permissions` serves is also the
* map the permission predicate reads, and the second consumer cannot tell a
* wrong map from a right one. `@objectstack/formula` states the hazard at its
* own door (`toEvalPermissions`): a hand-built permission map "has no shape of
* its own to be wrong against — it parses, `can()` answers from it, and the
* answer is a confident silent denial". So the merge is single-sourced here,
* and a consumer that needs the SETS still asks the sets method.
*
* **The WHOLE map, and there is no object parameter — that is a safety
* property, not an omission.** An entry the map omits is read as "no grant"
* and answers `false`, which is indistinguishable from a measured denial. A
* caller therefore cannot narrow the map to the objects it believes will be
* asked about: a predicate names its objects in its own source, the objects
* it names are not known to the site that assembles the context, and every
* object left out silently denies. Implementations return every object the
* subject's resolution mentions, plus the entries that resolution seeds — not
* the subset one call site asked for.
*
* **THROWS on resolution failure, exactly as {@link resolvePermissionSetNames}
* and {@link resolvePermissionSetsForContext} do — and ⛔ NEVER answers `{}`
* instead.** An empty map is a REAL answer here (this subject holds nothing,
* every `can()` is `false`), so a failure that degraded to `{}` would be
* published as a measured denial of everything. Callers fail CLOSED on the
* throw; ⛔ they may not read it as "no grants".
*
* **Request-scoped: resolve it ONCE per request, ⛔ never per evaluation and
* ⛔ never cached across requests.** The map is pinned data — an evaluator
* re-reads it for free — so re-resolving per predicate buys nothing and pays a
* full set resolution each time; holding one across requests serves a grant
* that may since have been revoked.
*
* **OPTIONAL, and absence is a defined state — not a bug.** A security service
* that predates this method omits it, and consumers feature-detect
* (`typeof svc.getEffectiveObjectPermissions === 'function'`). ⛔ The fallback
* for an absent method is NOT an empty map and NOT a locally merged one: a
* caller that cannot get this answer passes NO permission data at all, which
* leaves a permission-gated predicate loudly unevaluable instead of quietly
* denied — the same distinction the map's own consumer draws between "absent"
* and "empty". Declaring it optional is what makes that degradation a property
* of the type rather than a promise in prose: the unguarded call does not
* compile, so a consumer cannot skip the absent branch by accident.
*/
getEffectiveObjectPermissions?(
context?: SecurityContext,
): Promise<Readonly<Record<string, EffectiveObjectPermission>>>;

/**
* [#3544] Whether `context` may EXPORT `object` — the user-level export axis
* (`ObjectPermissionSchema.allowExport`).
Expand Down
Loading