Repository navigation
Positions, permission sets, sharing rules, FLS, onEnable bindings (card 04, M1) #4
Description
Activity
Claim: PM loop round 3
Session:session_01KcrVDXSptwDukFsHPHPR1V
Branch:claude/issue-4-security
Worktree:hotclm-issue-4
Domain:repo:hotclm(single-lane repo)
File surface:src/profiles/,src/sharing/,src/security/, plusobjectstack.config.ts(thesharingcapability token, thepermissions/positionskeys andonEnable— declared in advance, since security metadata cannot register from anywhere else)
Container & model:L,mode:subagent,model: fable(permission and sharing surfaces carry design judgment, and a wrong grant fails open)
Clause-②: no
Serial constraints cleared:Blocked-by: #2and#3are both satisfied — PR #5 merged asf3f2a4e, PR #9 as1817f95. Base ismain@1817f95(11 objects, 169 fields, signable-in). No other in-flight claim in this repo. Decision cards #6 and #10 touchDESIGN.md§03 state machines and field lists, not §04 — they do not block this card.
Generated by Claude Code
os-dev-report
{ "issue": 4, "status": "done", "branch": "claude/issue-4-security", "pr": "https://github.com/objectstack-ai/hotclm/pull/12", "premise_still_valid": true, "summary": "DESIGN.md §04 landed as metadata on main @ 1817f95: seven positions (src/sharing/positions.ts), five permission sets with the permission matrix, action gates, FLS locks and row-level write windows (src/profiles/*.profile.ts + _grants.ts), five criteria sharing rules plus the own_and_reports depth on clm_requester (src/sharing/contract.sharing.ts, _lifecycle.ts reads every status list from the objects), and the kernel:bootstrapped binder writing 12 sys_position_permission_set rows (src/security/bind-position-sets.ts, onEnable named export). requires gains sharing and hierarchy-security. Two platform rules were measured and coded around: RLS policies OR-merge across held sets (so each editing set carries a permissive companion window) and FLS merges most permissively only among declaring sets (so legal/admin open explicitly what clm_requester hides). §04 was not edited; the one sentence the platform cannot satisfy — clm_requester as everyone's default while it grants clm_requester.access, refused by lint security-anchor-high-privilege and the runtime's everyone-anchor gate — is filed as decision card #11 (unlabelled; PM to apply needs-user-decision), with distribution meanwhile via every position binding clm_requester and Setup grants for positionless employees.", "tests": "Gates on final commit 8440537: pnpm validate exit 0 ('✓ Validation passed', 'Security: 7 Positions 5 Permissions', one informational line naming @objectstack/security-enterprise for hierarchy-security — expected); pnpm lint exit 0 ('✓ All checks passed'); pnpm typecheck exit 0. Zone 2 #2 measured first without the token: validate exit 1 'uses readScope=own_and_reports, a HIERARCHY scope. Declare requires: [hierarchy-security]' (and writeScope). Browser: pnpm dev --seed-admin on OS_PORT=3106, '✓ Server is ready', boot diagnostics = 1 pre-existing ADR-0087 warning (identical on base, filed as #8 by card 03), nothing degraded. REST pass (5 real sessions via /api/v1/auth/sign-in/email, 80 calls, 0 HTTP>=500) and Console pass (Playwright, each persona through the real /_console/ login form, refusals re-taken from inside the authenticated page, 4 screenshots). Refusals with positive controls: requesters A/B list only their own (total 1 each), A GET CB 404 RECORD_NOT_FOUND / B GET CA 404, own GET 200, B PATCH CA 403 PERMISSION_DENIED, A PATCH own draft 200; legal lists total 2 and reads both 200, moves CA submitted→in_review→in_approval→approved (200, stamps written); finance with CA in_review: query total 0, GET CA 404 — after approval query total 1, GET CA 200; requester GET party 200 with no bank_account and no contact_phone (contact_email present), legal GET same party has bank_account true; Zone 2 #1: finance PATCH {status:'signing'} on approved CA → 403 '[Security] Field write denied: not permitted to edit [status] on clm_contract', positive control PATCH {payment_terms:'net_30'} → 200 read back net_30, sibling {governing_law:'DE'} → 403; requester PATCH while in_review 403 (RLS window), requester PATCH risk_level 403 (FLS), review internal_note hidden from requester (has_comments true / has_internal_note false) and present for legal, finance legal-stage review 403 (row CHECK) vs finance-stage 201, finance payment_plan insert 201 vs requester 403. Bindings: sys_position_permission_set clm rows = 12; sys_sharing_rule = 6 active. Reverse evidence: run 1 (before the explicit FLS re-opens) had legal lose bank_account and get 403 on internal_note; run 2 after the fix: bank_account true, review 201 — the same calls flipped. Ablation with rebuild/on-disk proof: not applicable — metadata-only app with no dist-resolved test subject; the negative/positive control pairs above are the measurement.", "mcp_calls": "5 — search_issues ×2 (targeted dedup returned 0; control query hit #6 on the same channel; REST answered 403 at the start of the run so search switched to MCP), issue_write ×1 (#11), create_pull_request ×1 (#12), add_issue_comment ×1 (this report)", "open_questions": [ { "question": "clm_requester is 'every employee's default' (§04) but a set carrying systemPermissions cannot bind to the everyone anchor (lint error security-anchor-high-privilege, runtime refusal). How is the default distributed? Filed as #11.", "options": ["A keep the tokens; §04 states the distribution (positions bind clm_requester too, positionless employees granted in Setup); report the platform gap upstream so isDefault can return", "B drop clm_requester.access, mark the set isDefault, gate the 我的合同 group on authentication (§04/§05 change)", "C add an eighth position clm_employee (§04 change)"], "recommendation": "A, because it changes no token semantics, is fully reversible once the platform distinguishes app capability tokens from platform system permissions, and keeps the five-token navigation scheme intact" }, { "question": "§04 lists six action gates but assigns them to no set; the PR derives the placement (manage_approval_rules→admin; manage_clauses→legal,admin; execute/archive→records,admin; terminate→legal,admin; approve_contract→requester,legal,finance,admin because §06 F5's first rung is the direct manager who holds only clm_requester). Accept the derivation?", "options": ["A accept the table as authored in the PR", "B maintainer reassigns one or more tokens (one-line edits in src/profiles)"], "recommendation": "A, because every row is forced by the matrix or by F5's rung-1 manager, and approve_contract on the requester set is the only placement under which that rung can act at all" } ], "out_of_scope_findings": [ "filed as #11: clm_requester default distribution vs the everyone-anchor high-privilege rule — decision card, left unlabelled for PM triage", "noted, not filed: no non-admin set can read sys_file — every requester/legal contract read logs 'find on sys_file is not permitted' then 'file fields keep their raw ids'; clm_contract_version.file is required, so the non-admin upload path may need a sys_file grant or a system-context upload route; not measured here", "noted, not filed: §05 says the records 台账 grid is exportable but allowExport is not granted yet — grant it in card 07 together with the list view's exportOptions (HotCRM discipline)", "noted, not filed: the eleven systemPermissions tokens are seeded untitled; defineCapability entries under capabilities: would give Setup labels and scope org — card 07's natural place", "noted, not filed: dual-hat composition rule documented in _grants.ts/legal.profile.ts/requester.profile.ts — any future set that locks or narrows must be mirrored by explicit open/permissive entries in every co-holdable set", "noted, not filed: clm_obligation is controlled_by_parent, so an obligation assigned to someone who cannot read the contract is invisible to them — a §03/§04 consequence for cards 07/09", "noted, not filed: hook refusals (422 INVALID_STATE) are logged by the platform as ERROR [BodyRunner] sandboxed hook threw although the client receives a clean 422 — card 02's design, platform log level", "noted, not filed: the binder's '[clm] position bindings ensured' line is info-level and invisible at the dev server's default log level; the evidence is the sys_position_permission_set count" ] }
Generated by Claude Code
ACCEPT — PM review, round 3 (M1 complete)
Reviewed against GitHub. PR #12, head
8440537.Item Verdict PR shape draft, base main,Fixes #4✓CI Validategreen on8440537✓File surface 14 files across src/profiles/,src/sharing/,src/security/plusobjectstack.config.ts— exactly the surface the claim declared in advance, no breach ✓Gates validate ✓ (7 Positions, 5 Permissions — matches §04's rosters) · lint ✓ · typecheck ✓ Capabilities requires: ['ui', 'auth', 'sharing', 'hierarchy-security']— both new tokens named and justified ✓Both Zone 2 assumptions resolved by measurement, and the first one matters beyond this card.
- FLS can lock
status— so ruling Q1 is implementable as written. Finance PATCH{status: 'signing'}on an approved contract →403 [Security] Field write denied: not permitted to edit [status] on clm_contract, with the positive control{payment_terms: 'net_30'}→ 200 and the value read back. That was the open risk when I answered Q1 on card 02; it is now closed with evidence rather than assumption. hierarchy-securityis genuinely required. Measured first without the token:validateexit 1,uses readScope=own_and_reports, a HIERARCHY scope. Declare requires: ['hierarchy-security']. Declaring it second is the right order — the requirement was established, not assumed.
Two platform behaviours discovered and designed around. These are the substance of the card and neither is in
DESIGN.md:- RLS policies OR-merge across held sets, so an editing set needs a permissive companion window or the narrower set widens the broader one.
- FLS merges most permissively only among declaring sets, so a lock in one set is silently void unless every co-holdable set also declares the field. Legal and admin therefore re-open explicitly what
clm_requesterhides.
The second is the kind of rule that fails open, and the run caught it the honest way: run 1, before the explicit re-opens, had legal lose
bank_accountand take a 403 oninternal_note; run 2 flipped the same calls. That is ablation-quality evidence for a security surface — the control was observed failing before it was observed passing.Every refusal carries its positive control, which is what I asked for and what makes the negatives mean anything:
Refusal observed Its control Requester A GETB's contract → 404A GETown → 200; each lists total 1B PATCHA's contract → 403A PATCHown draft → 200Finance GETanin_reviewcontract → 404, query total 0Same contract after approval → 200, total 1 Requester GETparty → nobank_account, nocontact_phoneLegal GETsame party →bank_accountpresentFinance PATCH status→ 403PATCH payment_terms→ 200, read backRequester PATCHduringin_review→ 403 (RLS window)— with the FLS refusal on risk_levelas a separate axisBindings verified as data, not as intent: 12
sys_position_permission_setrows, 6 active sharing rules. Both the REST pass (5 real sessions, 80 calls, zero 5xx) and a Console pass through the real login form.§04 was not edited. The one sentence the platform cannot satisfy —
clm_requesteras everyone's default while it carries a capability token — was raised as decision card #11 instead of resolved in a diff. That is the third card in a row to hit a governed-surface gap and route it correctly rather than widen the text.Open questions. Both are on #11, now labelled and with the second one added by me:
- How
clm_requesterreaches employees without a position (recommendation A: keep the tokens, state the distribution, report the platform gap upstream). - Which sets hold the six action gates — §04 lists them and assigns them to nobody. The derivation is forced by the matrix and by F5's rung-1 manager, and
approve_contracton the requester set is the only placement under which that rung can act at all. This is a permission boundary, so it is the maintainer's, not mine.
Neither blocks M1: both are one-line reversible edits in
src/profiles/, and the acceptance run staffed its personas through the Setup path.Merging: CI green, ACCEPT recorded, no governed surface touched.
M1 is complete with this card — 11 objects, the state machines, the roll-ups, and the full security model, each verified against a running app.
Generated by Claude Code
- FLS can lock
Milestone: M1 · Card:
docs/backlog/04-security.mdBlocked-by: #2
Blocked-by: #3
Scope
src/profiles/*.profile.ts(5 permission sets),src/sharing/positions.ts(7 positions),src/sharing/*.sharing.ts(6 rules), FLS declarations,src/security/bind-position-sets.ts+onEnableinobjectstack.config.ts. Addsrequires: ['sharing'].Spec — DESIGN.md §04, verbatim
Positions, sets, the permission matrix, the six sharing rules and the FLS table are pinned there (global-first revision, 2026-09-07: no seal keeper;
clm_records_managercovers execution and archive).Capabilities granted via
systemPermissions:clm_requester.access·clm_legal.access·clm_finance.access·
clm_records.access·clm_admin.access; action gatesapprove_contract·execute_contract·archive_contract·terminate_contract·manage_clauses·manage_approval_rules.contract_manager_reportsuseswriteScope: 'own_and_reports'only if declaring it does not require thehierarchy-securitycapability at validate time; if it does, declare the capability (it is safe on anopen-edition boot — see HotCRM's
objectstack.config.tsnote) and record the edition boundary in the PR.Bindings:
sys_position_permission_setrows cannot be seeds — bind onkernel:bootstrappedas ATS does.Ruling Q1 (2026-09-07): finance gets
editon contracts fromapprovedonward with FLS locking the legal fields andstatus— verify in this card that FLS can lockstatus; if it cannot, returnneeds_decisionwith the measured behavior instead of widening the grant.Acceptance
clm_legalaccount reads all; a
clm_financeaccount reads noin_reviewcontract.clm_party.bank_accountis absent from a requester's REST read.Out of scope
Views/app gating (07).