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
23 changes: 23 additions & 0 deletions .changeset/20604-explain-enforce-closeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/plugin-security': patch
'@objectstack/core': patch
---

fix(plugin-security): `security/explain` answers enforcement's refusal at the object level too, and explains another user in the organization they are resolved in (#20604)

Clause-②: no

Two answers of `POST /api/v1/security/explain` disagreed with what the same principal's own request gets from enforcement.

**A row-level policy that compares two fields of no shared comparison class** (text against a number, or any field against a file field, a formula field, or a field that holds a list or an object). The SQL driver refuses to compile such a read, so the find answers `INVALID_FILTER` / 400. A by-id update or delete fails closed at its row-level gate, and an insert whose check judges the policy is refused with `INVALID_FILTER` / 400. An object-level explanation (no `recordId`) still answered `allowed: true`, the `rls` layer `narrows`, and the predicate as `readFilter`, for every operation. A `recordId` that no row carries was answered `visible: false` with no deciding layer. Both are now refused with the envelope a record-grained explanation already gives: `INVALID_FILTER` / 400, with the message that names the policy and both fields. A request that the capability gate or the CRUD grant denies is still explained as denied there.

**Another user explained by an administrator.** The explanation now carries the organization the user is resolved in, as enforcement's context for that user does. Before, a current member of the administrator's organization was explained with no organization. Under `isolated`, that member was reported denied on a tenant object their own find reads. Under every posture, a permission set that their organization authored (a `sys_permission_set` row scoped to that organization) was missing from the explanation and from the verdicts it decides.

`@objectstack/core`: the API-key arm of `resolveAuthzContext` asks `vetOrganizationClaim` for its membership rule, as the session arm does. This is a refactor with no behaviour change. A key whose owner is no longer a member of its organization is still refused.

Unchanged:

- Enforcement admits and refuses exactly what it did before.
- A comparison between two fields of one class keeps its verdicts, at the object level and per record.
- Explaining yourself.
- A removed member's explanation (no organization, as enforcement resolves them).
14 changes: 11 additions & 3 deletions packages/core/src/security/resolve-authz-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,9 +451,13 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
// Degrading would hand back exactly the `200 + total 0` silent-empty this
// card exists to kill — an ex-member's automation would keep answering
// success while reading nothing.
if (keyPrincipal?.tenantId && input.tenancyPosture) {
const posture = input.tenancyPosture;
if (postureEnforcesWall(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId)) {
//
// [#20604] The membership rule is {@link vetOrganizationClaim}, the one the
// session arm below asks: a walled posture, and no current membership backing
// the key's organization. Only the CONSEQUENCE is this arm's own — the key is
// refused (#15256 2A) where the session arm drops the claim.
if (keyPrincipal?.tenantId) {
if (vetOrganizationClaim(keyPrincipal.tenantId, grants.accessible_org_ids, input.tenancyPosture) === undefined) {
// [#15256 / 2A] The `organization_membership_ended` decision point — AFTER
// grants, because the membership set is what decides it. One line, here.
warnApiKeyRefusal({
Expand Down Expand Up @@ -582,6 +586,10 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
* [#20580] A second reader asks it: the permission explainer, about the user
* it explains in the caller's organization. That is how `security/explain`
* resolves that user in the organization enforcement would resolve them in.
* [#20604] The API-key arm of {@link resolveAuthzContext} asks it too, about
* the organization a key is stamped with. The rule is the same; the
* consequence is that arm's own: a key IS its organization binding, so an
* unbacked key is refused (#15256 2A) where a session's claim is dropped.
* ⛔ Nothing else spells this rule — a caller that needs it calls this.
*/
export function vetOrganizationClaim(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { MatchesFilterOptions } from '@objectstack/formula';

/**
* The declared columns of one object declaration, in the shape the record
* matcher's comparison-class rule reads (`MatchesFilterOptions['fields']`, the
* spec's `crossFieldComparisonVerdict` over each column's `type` and
* `multiple`).
*
* ONE reading, used by the two judges that hand the matcher an object's
* columns: the row-level write check (#20355), which judges the image a write
* would store, and `security/explain` (#20431, #20604), which answers with the
* refusal enforcement gives the same predicate. Where each gets the
* declaration from is its own question; what the declaration SAYS about a
* column is this function's, so the two cannot read one declaration two ways.
*
* A field map keyed by name and a list of `{ name, … }` entries read the same.
* A declaration with no field map hands over no columns (`undefined`), and the
* matcher then judges values only: a missing declaration never manufactures a
* refusal. A column whose `type` is not a string is left out, so it is not
* judged.
*/
export function declaredComparisonColumns(declaration: unknown): MatchesFilterOptions | undefined {
const declared = (declaration as { fields?: unknown } | null | undefined)?.fields;
if (!declared || typeof declared !== 'object') return undefined;
const entries: Array<[string, unknown]> = Array.isArray(declared)
? (declared as Array<{ name?: unknown }>).filter((f) => f?.name).map((f) => [String(f.name), f])
: Object.entries(declared as Record<string, unknown>);
const fields: Record<string, { type: string; multiple: boolean }> = {};
for (const [name, decl] of entries) {
if (!decl || typeof decl !== 'object') continue;
const { type, multiple } = decl as { type?: unknown; multiple?: unknown };
if (typeof type !== 'string') continue;
fields[name] = { type, multiple: multiple === true };
}
return { fields };
}
Loading
Loading