Repository navigation
Positions, permission sets, sharing rules, FLS, onEnable bindings (card 04, M1) - #12
Merged
Merged
Conversation
…e position-set bindings DESIGN.md §04 as metadata: the seven flat positions, the five permission sets with the permission matrix, the six-row sharing table (five criteria rules plus the own_and_reports depth on clm_requester), the field-level security table, and the kernel:bootstrapped binder that writes the sys_position_permission_set rows a seed cannot. Why the shape: - "all contracts" for legal is spelled as `status in [every status]` read from the object, because the platform refuses a match-all criteria (ADR-0049). - every set that edits an object another set narrows with a row policy carries its own permissive policy: RLS policies OR-merge across the sets a person holds, and a set that says nothing would inherit the narrower window. - clm_requester is not isDefault: a set carrying a system permission is high-privilege by the platform's rule and cannot bind to `everyone`, so the default is distributed through every position's second binding. - requires gains `sharing` and `hierarchy-security`; validate refuses the own_and_reports scope without the latter (measured), and the open edition fails it closed to owner-only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
Field permissions merge most permissively, but only among the sets that DECLARE a field: a set that says nothing about clm_party.bank_account does not out-vote a co-held set that hides it. Measured on the first verification run: a legal user, who also holds clm_requester, lost the bank account on a party read and was refused internal_note on a review insert (403 Field write denied). Legal and admin now open every contract, signature, review and party field they edit explicitly (openAllExcept / open in _grants.ts), with the §04 stamps kept read-only on top. Second run: legal reads the bank account, records the review with its internal note, and the contract reaches approved — where the finance status lock (§13 Q1) was then measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4
DESIGN.md§04 as metadata: the seven flat positions, the five permission sets carrying the permission matrix, the six-row sharing table, the field-level-security table, and thekernel:bootstrappedbinder that writes thesys_position_permission_setrows a seed cannot.requiresgainssharingandhierarchy-security. §04 was not edited; the one place it cannot be satisfied as written on this platform version is filed as #11 (decision card, not labelled — PM triage).What changed
src/sharing/positions.tsdefinePosition, plusCLM_POSITIONname constants the rules and the binder spell from one place.src/sharing/contract.sharing.tscontract_legal_allis two rows, one per legal position — a rule has one recipient), alledit/readlevels and recipients per §04.src/sharing/_lifecycle.tsCONTRACT_STATUSES,POST_APPROVAL_STATUSES,EXECUTION_STATUSES,REQUESTER_EDITABLE_STATUSES,REVIEW_STAGES,OBLIGATION_STATUSES. A subset that names a value the object no longer declares throws at load, whichvalidatereports.src/profiles/*.profile.tsclm_requester·clm_legal·clm_finance·clm_records·clm_adminviadefinePermissionSet: the matrix, thesystemPermissions, the FLS entries and the row-level write windows.src/profiles/_grants.tsreadOnly/hidden/open/editableOnly/openAllExcept/fieldsInGroups), the RLSinListhelper, and the two shared lock lists (CONTRACT_STAMPED_FIELDS,CONTRACT_LEGAL_FIELDS). Every helper refuses a field the object does not declare.src/security/bind-position-sets.tssys_position_permission_setrows onkernel:bootstrapped, idempotent, organization-aware.objectstack.config.tsrequires: ['ui', 'auth', 'sharing', 'hierarchy-security'],positions/permissions/sharingRules, and the namedonEnableexport.The permission matrix, as authored
Every cell of §04 is an object grant; the parentheticals are row scope (sharing rules + row-level policies) and field locks (FLS). Legal, finance and records carry
readScope: 'own'onclm_contractexplicitly — their rows arrive through the sharing rules; the admin set carriesviewAllRecords/modifyAllRecords(+allowTransfer, authored so it is readable) and is the only set with delete.The six-row sharing table
Rows 1–5 are
sharingRules. Row 6,contract_manager_reports, is exactly what §04's own table says it is:readScope/writeScope: 'own_and_reports'on theclm_requestergrant (both axes — §13 Q2 names visibility, the table names writes).Two platform shapes, neither a change of meaning:
plugin-sharingrefuses a match-all rule at seed and bydefineRule, and a match-all row that slipped in would match NOTHING (isMatchAllCriteria, ADR-0049). Legal's "every contract" is spelledrecord.status in [every status], the list read fromclm_contract.status.options, so a new status cannot fall out of legal's reach silently.contract_legal_allnames two positions, so it iscontract_legal_all_counsel+contract_legal_all_head.The action gates — where each token lives, and why
§04 lists six gates but assigns them to no set; the matrix and §06 decide each one:
manage_approval_rulesclm_approval_ruleis RCUD for admin only.manage_clausesexecute_contract,archive_contractclm_records_managercovers execution and archive"; records is RU fromsigningonward.terminate_contractstatuson an active contract: finance'sstatusis locked by §13 Q1, records' edit is limited to execution/archive fields.approve_contracttype: 'manager'), who holds no position and therefore onlyclm_requester; the executive and general-manager rungs likewise hold only that set. WHICH approval is theirs is the flow's assignment, not the gate's.Field-level security
§04's five FLS rows plus the matrix parentheticals, per set:
clm_contractroute_*,approval_status, the 8 stage timestamps, the 4ai_*(= therouting/lifecycle/aifield groups, derived)risk_level,liability_capclm_review.internal_note;clm_party.bank_account,contact_phonelegalfield group +risk_level+liability_cap+status(§13 Q1); everyclm_partyfield exceptbank_name/bank_accountclm_review.internal_noteclm_contractfield exceptstatus(execution IS the signing → active transition theexecute_contractgate authorises) andarchive_no(F14); everyclm_signaturefield exceptstatus,formalities_done,executed_file,completed_atclm_party.bank_account,contact_phoneTwo platform rules the first verification run taught, both now in the code
draft/submittedwrite window would otherwise bind a legal user (who also holdsclm_requester) and lock them out of a contract in review. Every set that edits an object another set narrows carries its own permissive policy on the same object and operation, each spelled as the field's whole vocabulary read from the object (contract_legal_edit_any_status,review_legal_*_any_stage,obligation_legal_update_any; finance and records restate their sharing-rule windows). Admin carries none: the VAMA bits bypass business RLS and a policy there would be inert metadata.bank_accountand was refusedinternal_noteon a review insert (403 Field write denied: not permitted to edit [internal_note]) because the co-held requester set hides both and legal said nothing. Legal and admin now open every field they edit explicitly (openAllExcept/open) — HotCRM's #488 discipline. Run 2:has_bank_account: truefor legal, review insert201.clm_requester— how "所有员工默认" is distributed (#11)The platform's mechanism for "everyone's set" is
isDefault: true(auto-bind to theeveryoneanchor). A set carrying ANYsystemPermissionsis high-privilege bydescribeHighPrivilegeBits, so: the runtime would refuse the binding at boot with a warning, andos lintrefuses the declaration (security-anchor-high-privilege, error).clm_requestermust grantclm_requester.access(§04), so it is notisDefault. Instead every one of the seven positions bindsclm_requesteras well as its own set (12 rows), which is also the only way an executive holds an object-level read onclm_contractfor the rowscontract_executive_routedshares; an employee with no position receives the set in Setup. Options for the maintainer are in #11; nothing here pre-empts them.Zone 2 — the four mechanical assumptions, measured
status: TRUE. Finance (clm_finance_controller), on anapprovedcontract it can read and edit:PATCH /api/v1/data/clm_contract/6HJqqeDmOxs-NoTR {status:'signing'}→403 PERMISSION_DENIED — [Security] Field write denied: not permitted to edit [status] on 'clm_contract'. The lock is a refusal, not a silent strip (plugin-securitydetectForbiddenWritesthrows;stripNonEditableFieldshas no caller). Positive control on the same row, same session, one call later:PATCH {payment_terms:'net_30'}→200, read back by legal aspayment_terms: "net_30",status: "approved". Sibling negative:PATCH {governing_law:'DE'}→403 … not permitted to edit [governing_law]. The Q1 ruling is implementable as written.own_and_reportsdemandshierarchy-securityat validate time: TRUE. Without the token,pnpm validateexits 1:permission set 'clm_requester' grant on 'clm_contract' uses readScope='own_and_reports', a HIERARCHY scope. Declare requires: ['hierarchy-security'] (provided by @objectstack/security-enterprise) — the open edition cannot enforce it and would fail closed to owner-only.(and the same line forwriteScope). Declared, with the edition boundary recorded in the config note: on the open editionSharingService.resolveOwnerScopeIdshas no resolver and returns owner-only, which is the edition the M1 measurement below was taken on — a requester reads exactly their own contracts.validatethen prints one informational line naming the enterprise package; expected output, the same line HotCRM asserts.sharingis the only new token: FALSE, by one.hierarchy-securityis the second, for the reason in (2) — the card anticipated exactly this case. Nothing else asked for a token: the sharing rules and the FLS declarations validate, seed and enforce undersharingalone.validatereports 7 positions and 5 permissions: TRUE —Security: 7 Positions 5 Permissions(tail below).Gates
All three on the final commit,
git rev-parse --short HEAD=8440537.Browser evidence — every refusal observed, each with its positive control
pnpm dev --seed-adminonOS_PORT=3106, detached (setsid); Playwright 1.50 against/opt/pw-browsers/chromium-1194/chrome-linux/chrome. The Console has no renderedclm_*list or record surface yet (views arrive with cards 05/07 — card 03 recorded the same), so "the surface a person touches" is the authenticated data API. Two passes:verify-rest.mjs): five real sessions throughPOST /api/v1/auth/sign-in/email— the seeded admin, two requesters, a legal counsel, a finance controller — 80 calls,HTTP >= 500: 0.verify-ui.mjs): each persona signs in through the real/_console/login form (#login-email,#login-password, Sign In →/_console/home), and the same refusals are re-taken withfetchfrom inside that authenticated page. Screenshotsshot-01…04-*-home.pngshow the four signed-in homes ("No applications yet — apps your admin shares with you will show up here", the correct state before card 07).The boot, read
✓ Server is ready, then⚠ Boot diagnostics — 1 warning logged during startup— the ADR-0087field-required-notnull-explicitconversion of 40 sites, first atobjects[0].fields.name. Pre-existing and repo-wide: card 03's boot log carried the identical line on base, and it is filed as #8. Nodegraded capabilities, nono such table, no failed plugin. Nothing this card adds warns at boot: the everyone-anchor refusal thatisDefaultwould have caused does not appear, because the set is notisDefault.Staffing, measured
sys_position_permission_set(clm rows): 12 —clm_admin → clm_admin,clm_admin → clm_requester,clm_executive → clm_requester,clm_finance_controller → clm_finance,clm_finance_controller → clm_requester,clm_general_manager → clm_requester,clm_legal_counsel → clm_legal,clm_legal_counsel → clm_requester,clm_legal_head → clm_legal,clm_legal_head → clm_requester,clm_records_manager → clm_records,clm_records_manager → clm_requester. On the first boot of an empty database (the organization is created by the seed during boot), soonEnableonkernel:bootstrappedfound the catalog rows; the restart re-ran it idempotently (still 12).sys_sharing_rule: the six rows, allactive,edit/readas declared.POST /api/v1/auth/admin/create-user; L givenclm_legal_counseland Fclm_finance_controllerviaPOST /api/v1/data/sys_user_position; A and B givenclm_requesterviaPOST /api/v1/data/sys_user_permission_set(no position — the plain-employee path).GET /api/v1/auth/me/permissionsthen reports A: sets[clm_requester, member_default], positions[org_member, everyone]; L:[clm_legal, clm_requester, member_default]/[org_member, clm_legal_counsel, everyone]; F:[clm_finance, clm_requester, member_default]/[org_member, clm_finance_controller, everyone].Acceptance line 1 — two requesters cannot read each other's contracts
A creates CA (
201), B creates CB (201).POST clm_contract/querytotal: 1, ids[CA]— A does not see CBPOST clm_contract/querytotal: 1, ids[CB]— B does not see CAGET clm_contract/CBRECORD_NOT_FOUNDGET clm_contract/CARECORD_NOT_FOUNDGET clm_contract/CAGET clm_contract/CBPATCH clm_contract/CA {title}PERMISSION_DENIED"You do not have access to this record"PATCH clm_contract/CA {title}(draft)Repeated from the Console sessions of A and B with the same four outcomes (404/200/404/200).
Acceptance line 2 — a
clm_legalaccount reads allL
POST clm_contract/query→200,total: 2, titles["B's contract", "A's contract (edited while draft)"];GET CA→ 200;GET CB→ 200. L also moved CAsubmitted → in_review(200,review_started_atstamped) and laterin_review → in_approval → approved(200,approved_atstamped) — theeditlevel ofcontract_legal_allholds, and the hook-stamped timestamps pass the FLS lock because the check runs on the caller's payload.Acceptance line 3 — a
clm_financeaccount reads noin_reviewcontractWith CA
in_review: FPOST clm_contract/query→200,total: 0; FGET clm_contract/CA→ 404RECORD_NOT_FOUND. Positive control, same session, after legal approved CA: Fquery→total: 1, ids[CA];GET CA→ 200,status: "approved". From the Console session:GET CB(draft) → 404,GET CA(approved) → 200.Acceptance line 4 —
clm_party.bank_accountabsent from a requester's readA
GET clm_party/ZaPt8gpD6xie6L1p→200, keys:id … name, party_kind, country_code, registration_no, legal_representative, address, contact_name, contact_email, bank_name, risk_flag, …— nobank_account, nocontact_phone, whilecontact_emailandbank_namesurvive. Asking for it explicitly (querywithfields: ['id','name','bank_account','contact_phone']) returns keys["id","name"]. Positive control: LGETthe same party →has_bank_account: true, valueDE89 3704 0044 0532 0130 00,contact_phonepresent.The rest of §04, while the sessions were open
PATCH CA {title}whilein_review→ 403 (log:not permitted to update this 'clm_contract' record (row-level security)); the same call whiledraft→ 200 (above). From the Console, on the approved CA → 403.PATCH CA {risk_level:'high'}→ 403Field write denied: not permitted to edit [risk_level].internal_note: L records a legal review withinternal_note→201; AGETit →has_comments: true, has_internal_note: false; LGET→has_internal_note: true.POST clm_review {stage:'legal'}→ 403 "You are not allowed to save this record with the values you entered" (the row-level CHECK); FPOST clm_review {stage:'finance'}→ 201.POST clm_payment_planon the approved CA → 201; A the same → 403 (no create grant). While CA was stillin_review, F's child inserts were refused as "requires edit access to its master record … (row-level security)" — the parent derivation (ADR-0055) honours the finance window.200,submitted_at: "2026-09-07T13:56:32.794Z".Request-time log lines seen, and what they are
All expected refusals log as
WARN [Security] Access denied …(8 RLS, the FLS and CRUD denials above). TwoERROR [BodyRunner] sandboxed hook threware the state machine's own422 INVALID_STATErefusals from run 1 (card 02's hooks refuse by throwing; the platform logs a thrown hook at ERROR although the client received a clean 422). Also seen, none introduced here:sys_file lookup failed; file fields keep their raw idsafterfind on sys_file is not permittedfor requester and legal sessions on contract reads (noted below),find on sys_activity is not permittedfrom the Console home's activity feed for non-admin users, and the pre-existing Console[AuthProvider] Failed to load organizationswarning card 03 also reported.验收备注
Out-of-scope observations, none ridden into this PR:
DESIGN.md§04 makesclm_requesterevery employee's default, but a set that grantsclm_requester.accesscannot bind toeveryone#11 — theclm_requesterdistribution decision above (§04 wording vs the platform's high-privilege rule). Left unlabelled for triage.sys_fileis not readable by any non-admin set. Every requester/legal read of a contract loggedfind on object 'sys_file' is not permittedfollowed byfile fields keep their raw ids and will render as "no file". §04's matrix names nosys_*object, andclm_contract_version.fileis required, so the version-upload path for a requester (and legal, who upload the negotiation rounds) may need asys_filegrant or an upload route that runs under a system context. Not measured here (no upload was attempted); the first card that drives an upload as a non-admin should probe it before assuming.allowExportfor the records ledger. §05 says the 台账 grid is exportable (可导出); the bulk-egress bit is granted on the set, but HotCRM's discipline ties it to a list view declaringexportOptions, which card 07 authors. Grant it there in the same change.systemPermissionstokens are seeded implicitly (untitled) intosys_capability;defineCapabilityentries undercapabilities:would give Setup labels andscope: 'org'. Additive; card 07 gates on them and is the natural place._grants.ts,legal.profile.tsandrequester.profile.ts: any future set that locks or narrows must be mirrored by an explicit open/permissive entry in every set a person can hold beside it. Records'editableOnlylocks are countered in legal and admin; a finance + records dual hat (not a §04 shape) would inherit records' contract locks.clm_obligationiscontrolled_by_parent, so "我负责的履约" (§05) shows a requester only the obligations on contracts they can read; an obligation assigned to a colleague who cannot read the contract is invisible to them. A §03/§04 consequence for cards 07/09, not an implementation choice here.info.[clm] position bindings ensured {created, existing, skipped, declared}is emitted, but the dev server prints WARN and above by default; the boot-time evidence is thesys_position_permission_setcount, which the PR relies on.Generated by Claude Code