Skip to content

assertControlledByParentWrite answers a metadata defect and a missing row with the same 403 PERMISSION_DENIED "requires edit access to its master record" #7474

Description

@os-zhuang

Found while classifying the [Security] Access denied family for #7451 (PR #7471). Filed rather than fixed: that card is user-facing copy for the gates it converted, and this one is not a copy bug — the sentence is wrong because the branch is wrong, so fixing it decides something about the envelope.

What was measured

packages/plugins/plugin-security/src/security-plugin.tsassertControlledByParentWrite funnels six distinct conditions through one deny() helper, which composes one sentence and one code:

const deny = (reason: string, recordId?: unknown) => {
  throw new PermissionDeniedError(
    `[Security] Access denied: ${operation} on '${object}' requires edit access to its master record (${reason})`,
    { operation, object, recordId },
  );
};

The six reason values it is called with:

reason What actually happened Is "requires edit access to its master record" true?
no edit permission on master '…' caller lacks object-level update on the master yes
master '…' not editable by this user (row-level security) master row outside the caller's write RLS yes
master '…' not editable by this user (record sharing) no OWD / record-share edit grant yes
controlled_by_parent declared but no master_detail relation the app's metadata is broken — the object declares the sharing model with no relation to hang it on no
target record not found the detail row does not exist no
detail record has no master reference the row's master FK is null no

The bottom three are not authorization verdicts. The caller is told, with a 403, that they lack access to a record — when the truth is that their app is misconfigured, or the row is gone.

Why it matters in both directions

  • To the user, it is a false statement with a false remedy: "ask whoever owns the parent record" cannot fix a null FK or a missing master_detail relation.
  • To the app author, it is worse. controlled_by_parent declared but no master_detail relation is a genuine authoring defect that ObjectStack can detect precisely, and it is currently disguised as a routine permission denial — the class of thing an author will never see, because 403s on a detail object look like ordinary RBAC noise. This is the "declared = enforced, and say so loudly" principle inverted.
  • The first condition is also unreachable-looking but not unreachable: deny() returns never only by throwing, and if (!rel) deny(...) is followed by non-null assertions (rel!.fk), so the metadata-defect branch is load-bearing rather than defensive.

Note also that 'target record not found' turns a concurrent delete into a 403 rather than a 404 — an answer that leaks less but says something untrue, and the two transports already disagree about what a PERMISSION_DENIED body carries (#7450).

What a fix has to decide

  1. Do the three non-verdict conditions keep PERMISSION_DENIED, or get their own codes (a metadata/configuration error, and a not-found)? That is an ADR-0112 vocabulary question, not a copy edit — which is why this is filed rather than folded into fix(plugin-security,spec): the end-user-facing [Security] Access denied gates stop showing developer copy (#7451) #7471.
  2. Should the metadata-defect branch be a lint finding at publish time instead of (or as well as) a runtime refusal? controlled_by_parent with no master_detail relation is statically detectable.
  3. Once the branches are separated, the genuine-verdict half becomes convertible to the user-facing operation-message catalog exactly as fix(plugin-security,spec): the end-user-facing [Security] Access denied gates stop showing developer copy (#7451) #7471 converted the row-level gates — it is one of the end-user-facing gates that card identified but deliberately did not touch for this reason.

Pointers

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions