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
16 changes: 16 additions & 0 deletions .changeset/27332-external-owner-unmapped-warning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/lint': patch
---

feat(lint): `security-external-owner-unmapped` warns when a writable external object refuses every member write (#27332)

Clause-②: no (a warning narrows nothing; no accepted metadata shape moves)
Metadata-protocol: none

An external object (ADR-0015) on an owner-keyed sharing model (`private`, `public_read`, or none on a custom object) refuses every member edit and delete with 403 `PERMISSION_DENIED`, even for a member whose permission set grants `allowEdit`, unless an app-authored row-level policy admits the row. Before the write, record sharing reads the row's `owner_id` and the platform ownership floor reads `created_by` equal to the caller, and no remote row carries either column. `os validate`, `os build` and `os lint` now say so with a warning on the object.

- The warning names two ways out. One: declare `sharingModel: 'public_read_write'` and decide who may edit in a `beforeUpdate` hook. Two: declare `owner_id` in `fields` and have the driver return the remote owner under that name. A built-in SQL driver does that through `external.columnMap`; `columnMap` is not applied by the engine, so a plugin driver must return the field itself.
- A `columnMap` entry for `owner_id` without the declared field still warns, worded for it: it works on a built-in SQL driver only, and leaves reads unscoped by owner there.
- `ownership: 'none'` (or `'org'` / `'business_unit'`) is not a way out. It drops `owner_id`, and the floor's `created_by` check refuses the same write, so the warning still fires and names `created_by`. Declaring `created_by` (served by the driver) silences it.
- It skips a read-only binding (`external.writable` not `true`, or a datasource the stack declares with `external.allowWrites: false`), `public_read_write`, `controlled_by_parent`, and an object that declares the column its check reads.
- It is a warning, never an error: `defineStack` and every publish door accept the same stacks as before. `os explain security-external-owner-unmapped` explains the two checks, and why `public_read_write` opens edits but not deletes.
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@ os compile --json # JSON output for CI pipelines
→ Normalizing stack definition...
→ Lowering inline handlers...
→ Validating protocol compliance...
→ Running author-time rules (55)...
→ Running author-time rules (56)...
→ Checking that every required capability has a provider installable in this edition...
→ Collecting package docs (ADR-0046)... 0 collected
→ Writing artifact...
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -752,7 +752,7 @@ A clean run walks the registry and reports timing:
Config: /path/to/support-desk/objectstack.config.ts
Load time: 21ms
→ Validating against ObjectStack Protocol...
→ Running author-time rules (55)...
→ Running author-time rules (56)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...

Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ visible: 'status != "resolved"'
◆ Validate
────────────────────────────────────────
→ Validating against ObjectStack Protocol...
→ Running author-time rules (55)...
→ Running author-time rules (56)...

✗ Author-time rules failed (1 issue)
• stack · action 'resolve_ticket' visible: bare reference `status` — a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ objectstack validate
────────────────────────────────────────
→ Loading configuration...
→ Validating against ObjectStack Protocol...
→ Running author-time rules (55)...
→ Running author-time rules (56)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...

Expand Down
17 changes: 17 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ import { validatePredicatePathRefs } from './validate-predicate-path-refs.js';
import { validateSecurityPosture, validateSecurityRoleWord } from './validate-security-posture.js';
import { validateOrgAxisRedLines } from './validate-org-axis-red-lines.js';
import { validateSharingRuleEnforceability } from './validate-sharing-rule-enforceability.js';
import { validateExternalOwnerSharing } from './validate-external-owner-sharing.js';
import { validateRlsPredicateEnforceability } from './validate-rls-predicate-enforceability.js';
import {
validateMembershipKeyDeclarations,
Expand Down Expand Up @@ -2330,6 +2331,22 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
+ 'never run at a door should not claim it.',
run: (stack) => validateSharingRuleEnforceability(stack),
},
// A writable ADR-0015 external object on an owner-keyed sharing model whose
// owner column no remote row serves: record sharing reads `owner_id`, the
// platform ownership floor reads `created_by == caller`, neither has storage,
// so every member edit and delete is refused (403). Advisory: the stack is
// valid and an administrator still writes; a refusal would narrow the
// published accept set.
{
name: 'validateExternalOwnerSharing',
tier: 'advisory',
input: 'parsed',
commands: ALL,
source: 'packages/lint/src/validate-external-owner-sharing.ts',
surfaces: CLI_ONLY,
surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME,
run: (stack) => validateExternalOwnerSharing(stack),
},
// #4983 — the sibling surface of the rule above, and ADR-0056 D4's gate,
// which had never been wired to anything: `isSupportedRlsExpression` existed
// solely so an authoring command could reject a predicate the runtime drops,
Expand Down
11 changes: 11 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,17 @@ export type {
SharingRuleEnforceabilitySeverity,
} from './validate-sharing-rule-enforceability.js';

// A writable external object on an owner-keyed sharing model whose owner column
// no remote row serves refuses every member edit and delete (403).
export {
validateExternalOwnerSharing,
SECURITY_EXTERNAL_OWNER_UNMAPPED,
} from './validate-external-owner-sharing.js';
export type {
ExternalOwnerSharingFinding,
ExternalOwnerSharingSeverity,
} from './validate-external-owner-sharing.js';

// #4983 — the sibling surface, and ADR-0056 D4's gate finally wired. An RLS
// `using` / `check` the runtime cannot compile is DROPPED (and, when it is the
// only applicable policy, replaced by the deny sentinel), so the policy reads
Expand Down
44 changes: 44 additions & 0 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,49 @@ const SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN_EXPLANATION: RuleExplanation = {
],
};

const SECURITY_EXTERNAL_OWNER_UNMAPPED_EXPLANATION: RuleExplanation = {
rule: 'security-external-owner-unmapped',
covers: 'why members cannot write an external object',
paragraphs: [
'Before a member updates or deletes one record, the platform runs two row-level checks. Record ' +
'sharing, on a `private` or `public_read` object that carries an `owner_id` column, asks for the ' +
'row\'s `owner_id` and admits the owner (widened by write depth, an `edit` share or Modify All ' +
'Data). The platform ownership floor, `created_by` equal to the caller, stays in force unless record ' +
'sharing admits the write or the object declares `sharingModel: \'public_read_write\'`.',
'The registry adds `owner_id` and `created_by` to an external object as it does to every object, but ' +
'the remote database owns the table and the platform creates no column for either. So the remote row ' +
'answers no `owner_id` (record sharing refuses) and no `created_by` equal to the caller (the floor ' +
'refuses). A member whose permission set grants `allowEdit` is refused every edit and every delete ' +
'with 403 `PERMISSION_DENIED`, unless an app-authored row-level policy admits the row; otherwise only ' +
'an administrator can write. An object with no `sharingModel` is ' +
'judged as \'private\', the value the runtime falls back to.',
'`ownership: \'none\'` (or `\'org\'`, `\'business_unit\'`) is not a way out: without `owner_id` record ' +
'sharing stops judging the row, and the floor\'s `created_by` check refuses the same write. Turning ' +
'the audit columns off (`systemFields.audit: false`) does not help either: the floor then has no ' +
'column to compare and refuses every row.',
'There are two ways out. Declare `sharingModel: \'public_read_write\'`: every member who holds ' +
'`allowEdit` may then edit, and a `beforeUpdate` hook decides who really may. Or serve the column ' +
'the check reads: declare `owner_id` in `fields` and have the driver return the remote owner under ' +
'that name, so the owner edits and deletes the row and other members are refused. A built-in SQL ' +
'driver does that through `external.columnMap` (remote column to `owner_id`); `columnMap` is applied ' +
'by those drivers, not by the engine, so a plugin driver must return the field itself. Declaring ' +
'the field matters for reads too: the platform\'s own `owner_id` is treated as unstored, and reads ' +
'are not scoped by owner. On an object without `owner_id`, declare `created_by` the same way instead, ' +
'so the creator writes the row.',
'A `columnMap` entry for the column without a declared field is reported too, with its own wording: ' +
'it serves the check on a built-in SQL driver and does nothing on a plugin driver, and the ' +
'declaration is what holds on both.',
'Deletes are not opened by `public_read_write`: that model opens edits, and the floor keeps deletes ' +
'to the row\'s `created_by`. On an external object that column has no storage either, so member ' +
'deletes stay refused unless the driver returns `created_by` or an authored policy or share grants them.',
'The rule judges only a writable binding (`external.writable: true`, on a datasource this stack does ' +
'not declare with `external.allowWrites: false`) on a model that keys writes to ' +
'an owner (`private`, `public_read`, or none on a custom object). `controlled_by_parent` is not judged: ' +
'the master record\'s gate decides its writes. It is a warning, not an error: the stack is valid and ' +
'an administrator can still write.',
],
};

const SECURITY_FLS_UNKNOWN_FIELD_EXPLANATION: RuleExplanation = {
rule: 'security-fls-unknown-field',
covers: 'how the runtime resolves a field-permission key',
Expand Down Expand Up @@ -5150,6 +5193,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
[SECURITY_CBP_AMBIGUOUS_RELATION_EXPLANATION.rule]: SECURITY_CBP_AMBIGUOUS_RELATION_EXPLANATION,
[SECURITY_CBP_NO_RELATION_EXPLANATION.rule]: SECURITY_CBP_NO_RELATION_EXPLANATION,
[SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN_EXPLANATION.rule]: SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN_EXPLANATION,
[SECURITY_EXTERNAL_OWNER_UNMAPPED_EXPLANATION.rule]: SECURITY_EXTERNAL_OWNER_UNMAPPED_EXPLANATION,
[SECURITY_FLS_UNKNOWN_FIELD_EXPLANATION.rule]: SECURITY_FLS_UNKNOWN_FIELD_EXPLANATION,
[SECURITY_MASTER_DETAIL_UNGRANTED_EXPLANATION.rule]: SECURITY_MASTER_DETAIL_UNGRANTED_EXPLANATION,
[SECURITY_WILDCARD_VAMA_EXPLANATION.rule]: SECURITY_WILDCARD_VAMA_EXPLANATION,
Expand Down
Loading
Loading