Skip to content

clm_admin edits contracts past submitted: the administrator carries its own permissive update policies - #117

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-11-admin-cap-default-set
Oct 25, 2026
Merged

zhuangjianguo merged 1 commit into
mainfrom
claude/issue-11-admin-cap-default-set

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #11 — ruling steps 2–3: step 2 fixed in clm_admin (this app's own missing rule, measured on 18.1.0); step 3 measured and not adopted — isDefault: true on clm_requester is refused unless the stack declares its capability tokens, which lies outside this card's file surface.

#11 remains open: step 1 (the §04 gate × holder table) is governed text for the design-document PR, and step 3 waits on the decision below.

Step 2 — the clm_admin update cap

Re-measured on 18.1.0 (origin/main f5ad133, pnpm demo, persona Persona Admin holding position clm_admin): the defect is still there.

As clm_admin main f5ad133 this branch
PATCH clm_contract in draft / submitted 200 / 200 200 / 200
PATCH clm_contract in in_review · in_approval · approved · signing · active · terminated 403 PERMISSION_DENIED ×6 200 ×6
PATCH clm_contract in cancelled / expired (both archived) 403 / 403 422 INVALID_STATE "archived … only its summary can still be edited" — the app's own archive lock, which applies to everyone
PATCH clm_review under a contract past submitted (7 statuses) 403 ×7 "master 'clm_contract' not editable by this user (row-level security)" 200 ×7
PATCH clm_obligation owned by someone else (5 statuses) 403 ×5 200 ×5

Classification: (a) this app's own missing rule, not a platform defect. Evidence, from the installed @objectstack/plugin-security@18.1.0 and security/explain:

  • computeLayeredRlsFilter skips business RLS for a Modify All Data holder only when posturePermits && superuserBypass, and posturePermits is meta.isPrivate || tenancyDisabled || isBetterAuthManaged, where isPrivate is obj.access.default === 'private' — the ADR-0066 D2 exposure posture ("exempts it from wildcard RLS via the posture-gated superuser bypass"), not the OWD. clm_contract declares sharingModel: 'private' and no access block, so the bypass skips ownership and sharing but not RLS.
  • RLS policies from every held set OR-combine, and a set that declares none contributes none. security/explain for Persona Admin, update on an active contract, on main: vama_bypass → admitted via [clm_admin]; rls → excluded, rowFilter: {"status":{"$in":["draft","submitted"]}} — the requester's window alone. On this branch the same call's rls layer reads admitted with the admin's full-vocabulary policy OR-ed in.
  • That is documented platform behaviour this app already authors around: clm_legal carries contract_legal_edit_any_status for exactly this reason ("the OR-merge trap", legal.profile.ts). admin.profile.ts instead asserted that "the super-user bits also bypass business row-level security", which is true only on an access.default: 'private' object.

Fix: clm_admin carries contract_admin_edit_any_status (clm_contract update, every status) and obligation_admin_update_any (clm_obligation update, every status), each the whole vocabulary of its field read from the object — the same shape as the legal companions — and the header comment says why. No object, view or flow is touched; the §04 table is not changed.

Controls (this branch): Persona Requester owning one in_review and one submitted contract — PATCH 403 and 200 respectively: the requester window still holds. Persona Counsel (clm_legal): 15 of 17 edits 200, the other two the same archived 422s.

No upstream card: the seam is this app's metadata. (Setting access: { default: 'private' } on clm_contract would be the other route to the bypass; it also opts the object out of '*' wildcard grants, and it is an object edit, so it is not taken here.)

Step 3 — isDefault: true on clm_requester

Measured on 18.1.0 in three legs; none of it is committed.

Leg A — isDefault: true alone: refused. pnpm lint exit 1, pnpm validate exit 1, and objectstack dev --compile refuses to start, verbatim:

  ✗ Author-time rules failed (1 issue)
  • permission set "clm_requester": isDefault:true suggests binding this set to the 'everyone' audience anchor, but it carries system permissions — the runtime will refuse the binding (ADR-0090 D5/D9).
      fix: Split the powerful bits into a separate set granted through ordinary positions, and keep the everyone-suggested set low-privilege.
      rule: security-anchor-high-privilege  at permissions[4].isDefault
  ✗ Compile failed — fix errors above before starting dev server

The platform fix (objectstack#17189 / #18535) excuses a systemPermissions token only when the stack declares it under capabilities: (describeHighPrivilegeBits(def, { declaredCapabilities }); the lint rule reads stack.capabilities). This app declares none.

Leg B — plus a capabilities: block in objectstack.config.ts declaring the eleven tokens the five sets grant (scope: 'org'): accepted. pnpm lint 0, pnpm validate 0, boot clean (no refusal line), and security/explain for a brand-new member with no position and no grant (Persona Blank): permission sets [member_default] on main → [clm_requester, member_default], clm_requester via "additive baseline (ADR-0090 D5)"; create and read on clm_contract go from allowed: false to allowed: true.

Leg C — Leg B plus the seven RequesterSet.name entries removed from positions.ts: pnpm validate/lint/typecheck 0, and every persona's effective sets unchanged (explain principal, before vs after, same database):

Persona Effective sets, before = after
clm_admin clm_admin, clm_requester, member_default
clm_legal_counsel · clm_legal_head clm_legal, clm_requester, member_default
clm_finance_controller clm_finance, clm_requester, member_default
clm_records_manager clm_records, clm_requester, member_default
clm_executive · clm_general_manager clm_requester, member_default
requester (sys_user_permission_set row) clm_requester, member_default
no position, no grant member_default → clm_requester, member_default (the intended change)

Contract row counts per persona unchanged (executive 23, GM 4, requester 2, finance 82, records 82); the admin's step-2 edits still answer 200 — the ruling's warning holds: after the everyone binding the administrator still holds clm_requester, so step 2's fix is needed either way.

Why it is not in this PR: Leg B needs a capabilities: declaration in objectstack.config.ts, which is outside this card's file surface (src/profiles/** · src/sharing/positions.ts), and the retraction also makes README text false (below). It goes back to the seat as a decision.

Gates — head cc06d5d

pnpm install        exit 0
pnpm validate       exit 0   ✓ Validation passed (840ms)
pnpm lint           exit 0   6 suggestion(s), 0 errors
pnpm typecheck      exit 0   tsc --noEmit
pnpm lint:i18n-gate exit 0   ✓ i18n gate · COVERAGE : 0 missing keys across 2 locale(s)

Boot: the only boot-time warning on this branch is Plugin superseded: 'com.objectstack.metadata', present on main as well; no new warning.

Acceptance notes

  • README drift (not this card's surface): it still names src/security/bind-position-sets.ts (retired in PR Upgrade the @objectstack/* dependencies from 17.7.0 to 18.1.0, walking the official upgrade checklists of 18.0 and 18.1 #110), and says sys_user_permission_set.permission_set_id is id-typed — on 18.1 the field is permission_set, a name (POST /api/v1/data/sys_user_permission_set {"user_id": …, "permission_set": "clm_requester"} → 201). If step 3 lands, its "requesters hold no position … granted as a sys_user_permission_set row" and "the dev admin … holds no clm_* permission set" paragraphs change too.
  • security/explain attributes a set that a position definition distributes as via: "direct grant" (e.g. clm_legal and clm_requester for Persona Counsel, who has no sys_user_permission_set row); only a set whose name equals the position's (clm_admin) reads position:clm_admin. Platform-side, reported for the seat.

🤖 Generated with Claude Code

https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn

…ation

Every position also holds clm_requester, whose contract_requester_edit_window
(status in draft/submitted) and obligation_requester_own were the only update
policies on clm_contract and clm_obligation for an administrator. Modify All
Data skips ownership and sharing, but the platform drops business row-level
security for it only on an object whose posture is access.default 'private'
(ADR-0066 D2); the CLM objects declare an OWD, not that posture, so the
requester's window capped the administrator: PATCH on a contract past
submitted answered 403, and every child write under one was refused with
"master not editable (row-level security)". Measured on 18.1.0.

clm_admin now carries the same permissive companions clm_legal already does
(contract_admin_edit_any_status, obligation_admin_update_any), and the header
comment that claimed the super-user bits bypass RLS is corrected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — ACCEPT · head cc06d5d · 2026-10-25T13:18Z

check reading
shape draft → main, Part of #11, no closing keyword; assignee zhuangjianguo
CI on the head both Validate check-runs success on cc06d5d = PR head.sha; mergeable clean
scope 1 file, src/profiles/admin.profile.ts (+38/−4); nothing governed
the ruling 6395821523 step 2 — classified as this app's own missing rule, the branch the ruling says to fix directly: 18.1 skips business RLS for a Modify All Data holder only on an access.default: 'private' object (ADR-0066 D2), RLS policies OR-merge across held sets, so clm_requester's two windows were the admin's only update policies. Two permissive companions on clm_admin (same pattern as legal.profile.ts); the false header claim corrected. Not a platform workaround: the platform behaves as designed
measured (dev, 18.1.0) before: admin PATCH 403 on 6 post-submitted statuses, 403 on reviews under them, 403 on others' obligations; after: contracts 200 ×8 (cancelled / expired 422 by the app's archive lock, same for legal), reviews 200 ×7, obligations 200 ×5; requester window control still 403 / 200; boot warning set as main
stays on #11 step 1 (§04 gate × holder table, governed) and step 3: isDefault: true on clm_requester is refused by 18.1 lint/validate/compile (security-anchor-high-privilege) unless objectstack.config.ts declares the app's eleven capability tokens — outside the card and touching AGENTS.md's capability rule, so it goes to the maintainer as a decision

Landing: under the maintainer's merge authorization (project thread: 「你可以自己审核的就自己合并」).


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review October 25, 2026 13:18
@zhuangjianguo
zhuangjianguo merged commit bcf8eb9 into main Oct 25, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants