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
18 changes: 18 additions & 0 deletions .changeset/unpublished-object-deny-message.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
---
"@objectstack/plugin-security": patch
---

**Message change (no behaviour change):** a data-plane read against an object that exists only as an **unpublished draft** now says so, instead of reporting an internal security step (#10401).

The refusal itself is unchanged and stays fail-closed (#3545): same `PermissionDeniedError`, same `PERMISSION_DENIED` code, same HTTP 403, same `[Security] Access denied` prefix — which is a **matcher** the transports read as "this is a 403", not house style. Nothing here widens access, and no access decision branches on the new information.

What changed is what the refusal *says*. One sentence — "the security posture of object 'X' could not be resolved for operation 'find'" — covered two conditions with two different remedies, and described neither: because it named a *security* step, every reader took it for a permissions problem and went looking for a sharing rule to change. Measured downstream (objectstack-ai/cloud#1481): an end-user AI turn asked "how many customers do I have?" against a draft-only object, spent seven tool calls oscillating between a metadata plane that said the object existed and this refusal, then told the user the object was "missing its sharing/visibility setting" — confident, professional, and wrong. On a free plan that one turn also exhausted the daily allowance.

The two conditions are now separated:

- **The object has a `sys_metadata` draft and no published row** → *"object 'X' is not published — a draft declaration exists but no published one … Publish the object to make it queryable. This is NOT a permissions problem …"*.
- **The declaration genuinely cannot be read** (never declared, or a metadata-store outage) → the pre-existing clause **verbatim**, so any surface matching `the security posture of object 'X' could not be resolved for operation 'Y'` keeps matching, followed by the remedy and the same explicit statement that permissions are not the lever.

Both sentences, and the operator log line beside them, are derived from one module (`unresolved-posture.ts`) shared with the explain engine's `object_crud` layer detail. Enforcement and explanation stating one refusal in two drifting wordings is the defect shape this closes, so the wording is a single source rather than two literals.

The discriminator comes from a **best-effort** `sys_metadata` probe that runs only on the path already refusing, reads under a system context (so it cannot re-enter the middleware), and fails safe in one direction only: any failure — no `sys_metadata` in the deployment, an unprovisioned store, a driver error — reports the both-conditions wording rather than a claim. A posture that resolves never probes at all.
18 changes: 15 additions & 3 deletions packages/plugins/plugin-security/src/explain-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,10 @@ import type {
} from '@objectstack/spec/security';
import type { PermissionEvaluator } from './permission-evaluator.js';
import { superuserBypassBitForOperation } from './permission-evaluator.js';
import {
unresolvedPostureExplainDetail,
type UnresolvedPostureCause,
} from './unresolved-posture.js';

const SYSTEM_CTX = { isSystem: true } as const;

Expand DownExpand Up@@ -160,6 +164,13 @@ export interface ExplainEngineDeps {
fieldRequiredPermissions: Record<string, string[]>;
/** [#3545] Posture could not be read — the middleware denies (fail-closed). */
unresolved?: boolean;
/**
* [#10401] Which condition that is — an unpublished draft, or a declaration
* that genuinely cannot be read. Optional: a deps bag wired without it (or a
* deployment whose probe could not run) explains as `'unknown'`, the wording
* that covers both. Never an input to the verdict — only to the prose.
*/
unresolvedCause?: UnresolvedPostureCause;
}>;
/** The middleware's requiredPermissions AND-gate resolution for an operation. */
requiredCaps: (meta: any, engineOperation: string) => string[];
Expand DownExpand Up@@ -1118,9 +1129,10 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput
? `${operation} on '${object}' is granted by [${granting.join(', ')}]` +
(delegatorSets ? ' AND by the delegator (D10 intersection).' : '.')
: postureUnresolved
? `The security posture of '${object}' could not be resolved (neither the live schema nor the ` +
`metadata service returned it) — its 'private' flag and required-capability contract are ` +
`unknown, so access fails CLOSED rather than defaulting to public/uncontracted (#3545).`
// [#10401] One wording, shared with the middleware's own throw, so
// explanation and enforcement cannot drift into telling a reader two
// different things about one refusal. See `unresolved-posture.ts`.
? unresolvedPostureExplainDetail(object, secMeta.unresolvedCause ?? 'unknown')
: delegatorMissing
? `Delegator no longer exists — D10 fails closed (access denied).`
: agentCrud && !delegatorCrud
Expand Down
99 changes: 93 additions & 6 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,11 @@ import {
owdOpenWritesCoversOperation,
} from './platform-ownership-policies.js';
import { hasPhantomTenantAnchor } from './federated-phantom-anchors.js';
import {
unresolvedPostureDenialMessage,
unresolvedPostureLogLine,
type UnresolvedPostureCause,
} from './unresolved-posture.js';
import {
normalizeTenancyPosture,
postureEnforcesWall,
Expand DownExpand Up@@ -224,6 +229,19 @@ interface ObjectSecurityMeta {
* the permissive default withholds the exemption.
*/
unresolved: boolean;
/**
* [#10401] WHICH of the two conditions behind {@link ObjectSecurityMeta.unresolved}
* this is — the object exists only as an unpublished draft, or its declaration
* genuinely cannot be read. Meaningful only when `unresolved` is `true`; absent
* (and therefore `'unknown'`) on every resolved posture.
*
* ⛔ Explanation only. Nothing may branch an ACCESS DECISION on it: both causes
* deny, identically and fail-closed, and the probe that produces it is
* best-effort by design (see `probeUnpublishedDraft`). Its whole job is to stop
* the refusal naming a remedy — "change a sharing rule" — that cannot fix
* either condition.
*/
unresolvedCause?: UnresolvedPostureCause;
}

const EMPTY_REQUIRED_PERMISSIONS: NormalizedRequiredPermissions = Object.freeze({
Expand DownExpand Up@@ -1386,7 +1404,12 @@ export class SecurityPlugin implements Plugin {
const secMeta =
permissionSets.length > 0
? await this.getObjectSecurityMeta(opCtx.object)
: { isPrivate: false, tenancyDisabled: false, isBetterAuthManaged: false, requiredPermissions: EMPTY_REQUIRED_PERMISSIONS, fieldRequiredPermissions: {} as Record<string, string[]>, fieldMaskingRules: {} as Record<string, FieldMaskingRule>, unresolved: false };
// [#10401] `unresolvedCause` is spelled out rather than omitted so this
// stand-in and the real posture share one readable shape — the throw
// site below reads the key off the union. `undefined` is correct here:
// this branch declares `unresolved: false` by fiat (no permission sets
// were resolved, so no posture was read), and there is no cause.
: { isPrivate: false, tenancyDisabled: false, isBetterAuthManaged: false, requiredPermissions: EMPTY_REQUIRED_PERMISSIONS, fieldRequiredPermissions: {} as Record<string, string[]>, fieldMaskingRules: {} as Record<string, FieldMaskingRule>, unresolved: false, unresolvedCause: undefined as UnresolvedPostureCause | undefined };

// [#3545] Fail CLOSED when the object's own posture could not be resolved.
// #3545 accepted the API-exposure gate's fail-open on unresolvable metadata
Expand All@@ -1409,15 +1432,31 @@ export class SecurityPlugin implements Plugin {
// not by the permissive default — which is why the tiered decision recorded
// for the exposure gate (transient unavailability → fail open) can stay
// fail-open there while the boundary itself fails closed here.
//
// [#10401] …and the refusal has to SAY that, because the sentence it used
// to carry said something else. "The security posture could not be
// resolved" describes an internal security step, so every reader — human
// and model — read it as a permissions problem and went hunting for a
// sharing rule to change; and it covered two conditions with two
// different remedies (an unpublished draft, which is *publish it*, and a
// genuinely unreadable declaration, which is not). The DENY is unchanged
// — same `PermissionDeniedError`, same `PERMISSION_DENIED`, same 403 —
// only the explanation is now honest about which condition this is and
// about permissions not being the lever. Wording for both surfaces lives
// in `unresolved-posture.ts`; see its header for the measured cost of the
// old sentence.
if (secMeta.unresolved) {
const cause: UnresolvedPostureCause = secMeta.unresolvedCause ?? 'unknown';
ctx.logger.error(
`[security] object security posture unresolvable for operation '${opCtx.operation}' on ` +
`object '${opCtx.object}' (user ${opCtx.context?.userId ?? 'unknown'}) — ` +
`denying request (fail-closed, #3545)`,
unresolvedPostureLogLine(
opCtx.object,
opCtx.operation,
String(opCtx.context?.userId ?? 'unknown'),
cause,
),
);
throw new PermissionDeniedError(
`[Security] Access denied: the security posture of object '${opCtx.object}' ` +
`could not be resolved for operation '${opCtx.operation}'`,
unresolvedPostureDenialMessage(opCtx.object, opCtx.operation, cause),
{ operation: opCtx.operation, object: opCtx.object },
);
}
Expand DownExpand Up@@ -5545,11 +5584,59 @@ export class SecurityPlugin implements Plugin {
fieldRequiredPermissions,
fieldMaskingRules,
unresolved: !obj,
// [#10401] Explanation only, and only on the path that is already
// refusing. Both causes deny identically — see the field's TSDoc.
...(obj ? {} : { unresolvedCause: await this.probeUnpublishedDraft(object) }),
};
if (obj) this.objectSecurityMetaCache.set(object, meta);
return meta;
}

/**
* [#10401] Best-effort: is this unresolvable object simply an UNPUBLISHED
* draft?
*
* `sys_metadata` keys a pending edit as `state: 'draft'` and the published
* value as `state: 'active'`; an object that exists only as a draft therefore
* resolves from neither the live ObjectQL schema nor the metadata service (both
* serve published values), which is precisely how it lands on the #3545
* fail-closed branch wearing the same sentence as a genuinely missing
* declaration.
*
* Four properties this probe deliberately has:
*
* - **It runs ONLY on the unresolved path**, i.e. on a request that is already
* being refused. `getObjectSecurityMeta` returns before reaching here on
* every posture that resolves, so no authorized request pays for it. On the
* refusal path it is one indexed `sys_metadata` lookup beside the
* `metadata.get` that path already performs.
* - **It uses a SYSTEM context**, so the read short-circuits this very
* middleware at its `isSystem` guard rather than re-entering the posture
* resolution it is being called from.
* - **It fails SAFE, in one direction only.** Any failure — no `sys_metadata`
* in this deployment (file-backed metadata, LiteKernel test kernels), an
* unprovisioned store, a driver error — answers `'unknown'` and the caller
* gets the wording that covers both conditions. It can never turn a resolved
* posture into a denial, and it can never turn a refusal into a grant: it is
* read after the deny decision is already made.
* - **It is not org-scoped, and the wording is written so it does not need to
* be.** A draft row belonging to another organization would still make
* "a draft declaration exists but no published one" a true statement about
* this runtime, which is all the message claims.
*/
private async probeUnpublishedDraft(object: string): Promise<UnresolvedPostureCause> {
if (typeof this.ql?.findOne !== 'function') return 'unknown';
try {
const row = await this.ql.findOne('sys_metadata', {
where: { type: 'object', name: object, state: 'draft' },
context: { isSystem: true },
});
return row ? 'unpublished_draft' : 'unknown';
} catch {
return 'unknown';
}
}

/**
* [ADR-0066 D3] Fold per-field `requiredPermissions` into a FieldPermission map.
* A field whose declared capabilities are NOT all held by the caller is forced
Expand Down
Loading
Loading