@@ -1452,3 +1452,242 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', ()
14521452 expect ( seen ) . toHaveLength ( 0 ) ;
14531453 } ) ;
14541454} ) ;
1455+
1456+ // ─────────────────────────────────────────────────────────────────────
1457+ // [#6428] Tri-state write verdicts (#5492 ruling B, step 1).
1458+ //
1459+ // `canEdit` answered ONE `true` for two different facts — "I permit this
1460+ // write" and "record sharing does not enforce on this row at all". #5492's E2
1461+ // experiment delegated a pre-image write gate to it and measured the cost: on
1462+ // an object with NO `owner_id` column, an ordinary member's cross-creator
1463+ // UPDATE came back `ok: true` where `main` answers 403, because the platform's
1464+ // `created_by` ownership floor is that object's only row-level write gate and
1465+ // an abstaining `true` overrode it.
1466+ //
1467+ // `checkEdit` / `checkDelete` separate the two; `canEdit` / `canDelete` stay
1468+ // exactly as they were (`verdict !== 'deny'`), which the parity block below
1469+ // pins branch by branch so a two-state caller cannot drift under this change.
1470+ // ─────────────────────────────────────────────────────────────────────
1471+
1472+ describe ( '[#6428] SharingService.checkEdit / checkDelete — allow / abstain / deny' , ( ) => {
1473+ let engine : ReturnType < typeof makeFakeEngine > ;
1474+ let svc : SharingService ;
1475+
1476+ beforeEach ( ( ) => {
1477+ engine = makeFakeEngine ( {
1478+ account : ACCOUNT_SCHEMA , // private + owner_id → sharing enforces
1479+ whiteboard : CANON_PUBLIC_RW_SCHEMA , // public_read_write → sharing does not
1480+ note : ORPHAN_SCHEMA , // private, NO owner_id → the E2 shape
1481+ sys_record_share : { name : 'sys_record_share' } ,
1482+ } ) ;
1483+ svc = new SharingService ( { engine } ) ;
1484+ engine . _tables . account = [ { id : 'a1' , name : 'Acme' , owner_id : 'alice' } ] ;
1485+ engine . _tables . whiteboard = [ { id : 'w1' , name : 'Board' , owner_id : 'alice' } ] ;
1486+ engine . _tables . note = [ { id : 'n1' , body : 'an object with no owner column' } ] ;
1487+ } ) ;
1488+
1489+ it ( 'allow — the record owner, on both verbs' , async ( ) => {
1490+ expect ( await svc . checkEdit ( 'account' , 'a1' , { userId : 'alice' } ) ) . toBe ( 'allow' ) ;
1491+ expect ( await svc . checkDelete ( 'account' , 'a1' , { userId : 'alice' } ) ) . toBe ( 'allow' ) ;
1492+ } ) ;
1493+
1494+ it ( 'deny — a stranger on a sharing-enforced object' , async ( ) => {
1495+ expect ( await svc . checkEdit ( 'account' , 'a1' , { userId : 'bob' } ) ) . toBe ( 'deny' ) ;
1496+ expect ( await svc . checkDelete ( 'account' , 'a1' , { userId : 'bob' } ) ) . toBe ( 'deny' ) ;
1497+ } ) ;
1498+
1499+ it ( 'deny — a principal-less context (no userId is not "no opinion")' , async ( ) => {
1500+ expect ( await svc . checkEdit ( 'account' , 'a1' , { } ) ) . toBe ( 'deny' ) ;
1501+ expect ( await svc . checkDelete ( 'account' , 'a1' , { } ) ) . toBe ( 'deny' ) ;
1502+ } ) ;
1503+
1504+ it ( 'deny — a read-level share' , async ( ) => {
1505+ await svc . grant (
1506+ { object : 'account' , recordId : 'a1' , recipientId : 'bob' , accessLevel : 'read' } ,
1507+ { isSystem : true } ,
1508+ ) ;
1509+ expect ( await svc . checkEdit ( 'account' , 'a1' , { userId : 'bob' } ) ) . toBe ( 'deny' ) ;
1510+ } ) ;
1511+
1512+ it ( '[ADR-0111 D3] an edit share is allow on update and DENY on delete — the verb boundary survives the tri-state' , async ( ) => {
1513+ await svc . grant (
1514+ { object : 'account' , recordId : 'a1' , recipientId : 'bob' , accessLevel : 'edit' } ,
1515+ { isSystem : true } ,
1516+ ) ;
1517+ expect ( await svc . checkEdit ( 'account' , 'a1' , { userId : 'bob' } ) ) . toBe ( 'allow' ) ;
1518+ // NOT `abstain`: the delete gate has a real opinion here and it is "no".
1519+ // An abstain would hand the row to a caller's fallback and let an edit
1520+ // share leak the delete verb through the back door.
1521+ expect ( await svc . checkDelete ( 'account' , 'a1' , { userId : 'bob' } ) ) . toBe ( 'deny' ) ;
1522+ } ) ;
1523+
1524+ it ( '[#5492 E2] abstain — an object with NO owner_id column, where the boolean said `true`' , async ( ) => {
1525+ // THE pin this card exists for. The verdict is "I do not enforce here",
1526+ // so #5492 step 2 can keep the platform `created_by` floor in force…
1527+ expect ( await svc . checkEdit ( 'note' , 'n1' , { userId : 'bob' } ) ) . toBe ( 'abstain' ) ;
1528+ expect ( await svc . checkDelete ( 'note' , 'n1' , { userId : 'bob' } ) ) . toBe ( 'abstain' ) ;
1529+ // …while the two-state projection every current caller reads is unchanged.
1530+ expect ( await svc . canEdit ( 'note' , 'n1' , { userId : 'bob' } ) ) . toBe ( true ) ;
1531+ expect ( await svc . canDelete ( 'note' , 'n1' , { userId : 'bob' } ) ) . toBe ( true ) ;
1532+ } ) ;
1533+
1534+ it ( 'abstain — a public object, and an object this engine has no schema for' , async ( ) => {
1535+ expect ( await svc . checkEdit ( 'whiteboard' , 'w1' , { userId : 'bob' } ) ) . toBe ( 'abstain' ) ;
1536+ expect ( await svc . checkDelete ( 'whiteboard' , 'w1' , { userId : 'bob' } ) ) . toBe ( 'abstain' ) ;
1537+ expect ( await svc . checkEdit ( 'ghost' , 'g1' , { userId : 'bob' } ) ) . toBe ( 'abstain' ) ;
1538+ } ) ;
1539+
1540+ it ( 'the two bypass reasons split: a system context ALLOWS, a bypass-listed object ABSTAINS' , async ( ) => {
1541+ // Merging these was the ambiguity in miniature. A platform-internal writer
1542+ // is positively permitted; `sys_user` is merely not sharing-enforced, and
1543+ // saying `allow` there would invite a composing caller to skip the gate
1544+ // that actually guards those tables.
1545+ expect ( await svc . checkEdit ( 'account' , 'a1' , { isSystem : true } ) ) . toBe ( 'allow' ) ;
1546+ expect ( await svc . checkDelete ( 'account' , 'a1' , { isSystem : true } ) ) . toBe ( 'allow' ) ;
1547+ expect ( await svc . checkEdit ( 'sys_user' , 'u1' , { userId : 'bob' } ) ) . toBe ( 'abstain' ) ;
1548+ expect ( await svc . checkDelete ( 'sys_user' , 'u1' , { userId : 'bob' } ) ) . toBe ( 'abstain' ) ;
1549+ } ) ;
1550+
1551+ it ( '[#4647] allow — Modify All Data, still asked LAST' , async ( ) => {
1552+ let probeCalls = 0 ;
1553+ const withBypass = new SharingService ( {
1554+ engine,
1555+ securityService : ( ) => ( {
1556+ hasWriteBypass : async ( ) => { probeCalls ++ ; return true ; } ,
1557+ } ) ,
1558+ } ) ;
1559+ expect ( await withBypass . checkEdit ( 'account' , 'a1' , { userId : 'bob' } ) ) . toBe ( 'allow' ) ;
1560+ expect ( await withBypass . checkDelete ( 'account' , 'a1' , { userId : 'bob' } ) ) . toBe ( 'allow' ) ;
1561+ expect ( probeCalls ) . toBe ( 2 ) ;
1562+ // The owner never pays for the probe: ownership answers first.
1563+ probeCalls = 0 ;
1564+ expect ( await withBypass . checkEdit ( 'account' , 'a1' , { userId : 'alice' } ) ) . toBe ( 'allow' ) ;
1565+ expect ( probeCalls ) . toBe ( 0 ) ;
1566+ } ) ;
1567+ } ) ;
1568+
1569+ describe ( '[#6428] fail-closed: an unresolvable verdict is DENY, never abstain' , ( ) => {
1570+ let engine : ReturnType < typeof makeFakeEngine > ;
1571+ let logged : any [ ] ;
1572+ let svc : SharingService ;
1573+
1574+ beforeEach ( ( ) => {
1575+ engine = makeFakeEngine ( {
1576+ account : ACCOUNT_SCHEMA ,
1577+ sys_record_share : { name : 'sys_record_share' } ,
1578+ } ) ;
1579+ logged = [ ] ;
1580+ svc = new SharingService ( {
1581+ engine,
1582+ logger : { error : ( ...args : any [ ] ) => { logged . push ( args ) ; } } ,
1583+ } ) ;
1584+ engine . _tables . account = [ { id : 'a1' , name : 'Acme' , owner_id : 'alice' } ] ;
1585+ } ) ;
1586+
1587+ it ( 'a throwing ownership lookup denies — and says so in the log' , async ( ) => {
1588+ // Non-vacuity: this caller is ALLOWED while the engine works.
1589+ expect ( await svc . checkEdit ( 'account' , 'a1' , { userId : 'alice' } ) ) . toBe ( 'allow' ) ;
1590+
1591+ engine . find = async ( ) => { throw new Error ( 'engine down' ) ; } ;
1592+
1593+ // `abstain` here would be the fail-open: it tells a composing caller
1594+ // "nobody objects", on a row this service is supposed to be guarding.
1595+ expect ( await svc . checkEdit ( 'account' , 'a1' , { userId : 'alice' } ) ) . toBe ( 'deny' ) ;
1596+ expect ( await svc . checkDelete ( 'account' , 'a1' , { userId : 'alice' } ) ) . toBe ( 'deny' ) ;
1597+ // The boolean projection converges with it: a denial, not a thrown 500.
1598+ expect ( await svc . canEdit ( 'account' , 'a1' , { userId : 'alice' } ) ) . toBe ( false ) ;
1599+ expect ( await svc . canDelete ( 'account' , 'a1' , { userId : 'alice' } ) ) . toBe ( false ) ;
1600+
1601+ expect ( logged . length ) . toBeGreaterThan ( 0 ) ;
1602+ expect ( String ( logged [ 0 ] [ 0 ] ) ) . toContain ( 'fail-closed' ) ;
1603+ expect ( String ( logged [ 0 ] [ 0 ] ) ) . toContain ( '#6428' ) ;
1604+ } ) ;
1605+
1606+ it ( 'a throwing SHARE lookup denies too — the whole evaluation is covered, not just the first query' , async ( ) => {
1607+ await svc . grant (
1608+ { object : 'account' , recordId : 'a1' , recipientId : 'bob' , accessLevel : 'edit' } ,
1609+ { isSystem : true } ,
1610+ ) ;
1611+ // Non-vacuity: the share is what makes this caller `allow`…
1612+ expect ( await svc . checkEdit ( 'account' , 'a1' , { userId : 'bob' } ) ) . toBe ( 'allow' ) ;
1613+
1614+ const realFind = engine . find . bind ( engine ) ;
1615+ engine . find = async ( object : string , options ?: any ) => {
1616+ if ( object === 'sys_record_share' ) throw new Error ( 'share table unavailable' ) ;
1617+ return realFind ( object , options ) ;
1618+ } ;
1619+
1620+ // …so losing exactly that lookup must refuse, never fall back to "I have
1621+ // no opinion about a row I was gating a second ago".
1622+ expect ( await svc . checkEdit ( 'account' , 'a1' , { userId : 'bob' } ) ) . toBe ( 'deny' ) ;
1623+ } ) ;
1624+ } ) ;
1625+
1626+ describe ( '[#6428] the boolean projection does not drift (compatibility clause)' , ( ) => {
1627+ let engine : ReturnType < typeof makeFakeEngine > ;
1628+ let svc : SharingService ;
1629+
1630+ beforeEach ( async ( ) => {
1631+ engine = makeFakeEngine ( {
1632+ account : ACCOUNT_SCHEMA ,
1633+ whiteboard : CANON_PUBLIC_RW_SCHEMA ,
1634+ note : ORPHAN_SCHEMA ,
1635+ sys_record_share : { name : 'sys_record_share' } ,
1636+ } ) ;
1637+ svc = new SharingService ( { engine } ) ;
1638+ engine . _tables . account = [ { id : 'a1' , name : 'Acme' , owner_id : 'alice' } ] ;
1639+ engine . _tables . whiteboard = [ { id : 'w1' , name : 'Board' , owner_id : 'alice' } ] ;
1640+ engine . _tables . note = [ { id : 'n1' , body : 'no owner column' } ] ;
1641+ await svc . grant (
1642+ { object : 'account' , recordId : 'a1' , recipientId : 'carol' , accessLevel : 'edit' } ,
1643+ { isSystem : true } ,
1644+ ) ;
1645+ } ) ;
1646+
1647+ // Every branch of both gates, with the boolean answer `main` gives today.
1648+ // `resolveSharingCanEdit` (plugin-security :3484) and the `sys_attachment`
1649+ // parent gate read exactly this column, so a single flipped cell here is a
1650+ // silent enforcement change in packages this PR does not touch.
1651+ const CASES : Array < {
1652+ what : string ;
1653+ object : string ;
1654+ id : string ;
1655+ ctx : any ;
1656+ edit : boolean ;
1657+ del : boolean ;
1658+ } > = [
1659+ { what : 'owner' , object : 'account' , id : 'a1' , ctx : { userId : 'alice' } , edit : true , del : true } ,
1660+ { what : 'stranger' , object : 'account' , id : 'a1' , ctx : { userId : 'bob' } , edit : false , del : false } ,
1661+ { what : 'edit-share holder' , object : 'account' , id : 'a1' , ctx : { userId : 'carol' } , edit : true , del : false } ,
1662+ { what : 'no principal' , object : 'account' , id : 'a1' , ctx : { } , edit : false , del : false } ,
1663+ { what : 'system context' , object : 'account' , id : 'a1' , ctx : { isSystem : true } , edit : true , del : true } ,
1664+ { what : 'public object' , object : 'whiteboard' , id : 'w1' , ctx : { userId : 'bob' } , edit : true , del : true } ,
1665+ { what : 'no owner column' , object : 'note' , id : 'n1' , ctx : { userId : 'bob' } , edit : true , del : true } ,
1666+ { what : 'unknown object' , object : 'ghost' , id : 'g1' , ctx : { userId : 'bob' } , edit : true , del : true } ,
1667+ { what : 'bypass-listed object' , object : 'sys_user' , id : 'u1' , ctx : { userId : 'bob' } , edit : true , del : true } ,
1668+ ] ;
1669+
1670+ it ( 'canEdit / canDelete are `verdict !== deny` on every branch, and unchanged from the two-state era' , async ( ) => {
1671+ for ( const c of CASES ) {
1672+ const editVerdict = await svc . checkEdit ( c . object , c . id , c . ctx ) ;
1673+ const deleteVerdict = await svc . checkDelete ( c . object , c . id , c . ctx ) ;
1674+ expect ( await svc . canEdit ( c . object , c . id , c . ctx ) , `canEdit — ${ c . what } ` ) . toBe ( c . edit ) ;
1675+ expect ( await svc . canDelete ( c . object , c . id , c . ctx ) , `canDelete — ${ c . what } ` ) . toBe ( c . del ) ;
1676+ expect ( editVerdict !== 'deny' , `projection parity, canEdit — ${ c . what } ` ) . toBe ( c . edit ) ;
1677+ expect ( deleteVerdict !== 'deny' , `projection parity, canDelete — ${ c . what } ` ) . toBe ( c . del ) ;
1678+ }
1679+ } ) ;
1680+
1681+ it ( 'the abstain set is exactly where the boolean `true` carried no permission' , async ( ) => {
1682+ // Anti-vacuity for the table above: three of its `true` cells are abstains,
1683+ // not allows — which is the whole content of this change. If a later edit
1684+ // made these `allow`, the parity test would stay green and the fail-open
1685+ // would be back.
1686+ for ( const [ object , id ] of [ [ 'whiteboard' , 'w1' ] , [ 'note' , 'n1' ] , [ 'ghost' , 'g1' ] , [ 'sys_user' , 'u1' ] ] ) {
1687+ expect ( await svc . checkEdit ( object , id , { userId : 'bob' } ) , `${ object } must abstain` ) . toBe ( 'abstain' ) ;
1688+ }
1689+ // …and the ones that are real permissions stay `allow`.
1690+ expect ( await svc . checkEdit ( 'account' , 'a1' , { userId : 'alice' } ) ) . toBe ( 'allow' ) ;
1691+ expect ( await svc . checkEdit ( 'account' , 'a1' , { isSystem : true } ) ) . toBe ( 'allow' ) ;
1692+ } ) ;
1693+ } ) ;
0 commit comments