Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +7 -1
- VERSION +1 -1
- api/routes_shares.py +789 -627
- platform/core/shares.py +560 -414
RELEASES.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
{
|
| 5 |
"version": "v52",
|
| 6 |
"sha": "8263493",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "v53 (89ed2cb)",
|
| 3 |
"releases": [
|
| 4 |
+
{
|
| 5 |
+
"version": "v53",
|
| 6 |
+
"sha": "89ed2cb",
|
| 7 |
+
"date": "2026-08-24",
|
| 8 |
+
"subject": "release v53"
|
| 9 |
+
},
|
| 10 |
{
|
| 11 |
"version": "v52",
|
| 12 |
"sha": "8263493",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
v53 (89ed2cb)
|
api/routes_shares.py
CHANGED
|
@@ -1,627 +1,789 @@
|
|
| 1 |
-
"""routes_shares.py β the manage-access surface (wave 20, owner ruling R10, contract C-SHARE).
|
| 2 |
-
|
| 3 |
-
GET /api/v1/share/{kind}/{oid} -> {owner, entries:[{user,role}], mayAdminister, people}
|
| 4 |
-
PUT /api/v1/share/{kind}/{oid} <- {entries:[{user,role}]} (REPLACES the set)
|
| 5 |
-
GET /api/v1/share/mine -> {view:[id], folder:[id], database:[id]}
|
| 6 |
-
|
| 7 |
-
`kind` β view | folder | database | field. Roles are `view` | `edit` β the same two words the
|
| 8 |
-
view rail already speaks, now extended to folders, databases and COLUMNS so there is ONE
|
| 9 |
-
vocabulary in the UI (R10: "the same picker views use").
|
| 10 |
-
|
| 11 |
-
ββ **W38-T16 β `field` IS THE FOURTH KIND, AND ITS `oid` IS TOPIC-QUALIFIED: `"<table_key>:<field_key>"`**
|
| 12 |
-
(`shares.field_oid`). A bare column key repeats across databases β `notes` exists on a dozen β
|
| 13 |
-
so a grant stored under one would admit the grantee to every `notes` column in the tenant at
|
| 14 |
-
once. β THREE functions in this file branch on kind and ALL THREE need the new one, which is not
|
| 15 |
-
obvious because only two of them fail loudly: `_owns_object` (without it a column's own creator
|
| 16 |
-
is 404'd trying to share the thing they just made) and `_object_ref` (without it `route` is None,
|
| 17 |
-
`_notify_new_grantees` returns early, and the grantee is **granted and never told** β owner item
|
| 18 |
-
18's silent half, reopened one kind over). `_can_see_object` stays deliberately CLOSED for
|
| 19 |
-
anything that is not a view.
|
| 20 |
-
|
| 21 |
-
ββ **A "Can edit" GRANTEE MAY RE-SHARE A VIEW β OWNER RULING R4, BUILT AS W40-T02** (instruction
|
| 22 |
-
4: *"Edit View so a member can share a View as well, not just an admin"*). This REVERSES the flat
|
| 23 |
-
owner-or-admin sentence that stood here, and the reversal is bounded three ways, all enforced HERE
|
| 24 |
-
and not in the client:
|
| 25 |
-
|
| 26 |
-
1. **ONLY `kind='view'`.** `shares.RESHARE_KINDS` is the one spelling of that. `folder`,
|
| 27 |
-
`database` and `field` still require the owner or an admin, and the paragraph below is why
|
| 28 |
-
the `database` kind in particular was never a candidate: a `ut_*` grant's blast radius is a
|
| 29 |
-
whole table, where a view is one saved SELECTION over rows the receiver's own wall governs.
|
| 30 |
-
2. **A RE-SHARE MAY NEVER EXCEED THE RE-SHARER'S OWN ROLE**, and the strict reading ships: an
|
| 31 |
-
`edit` grantee may hand out `view` and NEVER `edit`. `shares.max_grantable_role` answers the
|
| 32 |
-
ceiling, `put_share` enforces it on the DELTA (a name arriving at `edit`, or one raised to
|
| 33 |
-
it) β never on every row of the body, because the `PUT` REPLACES and `ShareDialog.save`
|
| 34 |
-
therefore re-sends the whole list with the re-sharer's own `edit` row inside it. Conferring
|
| 35 |
-
`edit` stays the owner's alone.
|
| 36 |
-
3. **OWNERSHIP NEVER MOVES.** A caller re-sharing rather than owning passes the EXISTING owner
|
| 37 |
-
straight through (`put_share` below, stated rather than incidental).
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
`
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
β **
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
""
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
#
|
| 101 |
-
#
|
| 102 |
-
#
|
| 103 |
-
#
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
""
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
that
|
| 306 |
-
"""
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
""
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
if
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
#
|
| 427 |
-
#
|
| 428 |
-
#
|
| 429 |
-
#
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
#
|
| 433 |
-
#
|
| 434 |
-
#
|
| 435 |
-
#
|
| 436 |
-
#
|
| 437 |
-
#
|
| 438 |
-
#
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
if
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
#
|
| 493 |
-
#
|
| 494 |
-
#
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
β
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""routes_shares.py β the manage-access surface (wave 20, owner ruling R10, contract C-SHARE).
|
| 2 |
+
|
| 3 |
+
GET /api/v1/share/{kind}/{oid} -> {owner, entries:[{user,role}], mayAdminister, people}
|
| 4 |
+
PUT /api/v1/share/{kind}/{oid} <- {entries:[{user,role}]} (REPLACES the set)
|
| 5 |
+
GET /api/v1/share/mine -> {view:[id], folder:[id], database:[id]}
|
| 6 |
+
|
| 7 |
+
`kind` β view | folder | database | field. Roles are `view` | `edit` β the same two words the
|
| 8 |
+
view rail already speaks, now extended to folders, databases and COLUMNS so there is ONE
|
| 9 |
+
vocabulary in the UI (R10: "the same picker views use").
|
| 10 |
+
|
| 11 |
+
ββ **W38-T16 β `field` IS THE FOURTH KIND, AND ITS `oid` IS TOPIC-QUALIFIED: `"<table_key>:<field_key>"`**
|
| 12 |
+
(`shares.field_oid`). A bare column key repeats across databases β `notes` exists on a dozen β
|
| 13 |
+
so a grant stored under one would admit the grantee to every `notes` column in the tenant at
|
| 14 |
+
once. β THREE functions in this file branch on kind and ALL THREE need the new one, which is not
|
| 15 |
+
obvious because only two of them fail loudly: `_owns_object` (without it a column's own creator
|
| 16 |
+
is 404'd trying to share the thing they just made) and `_object_ref` (without it `route` is None,
|
| 17 |
+
`_notify_new_grantees` returns early, and the grantee is **granted and never told** β owner item
|
| 18 |
+
18's silent half, reopened one kind over). `_can_see_object` stays deliberately CLOSED for
|
| 19 |
+
anything that is not a view.
|
| 20 |
+
|
| 21 |
+
ββ **A "Can edit" GRANTEE MAY RE-SHARE A VIEW β OWNER RULING R4, BUILT AS W40-T02** (instruction
|
| 22 |
+
4: *"Edit View so a member can share a View as well, not just an admin"*). This REVERSES the flat
|
| 23 |
+
owner-or-admin sentence that stood here, and the reversal is bounded three ways, all enforced HERE
|
| 24 |
+
and not in the client:
|
| 25 |
+
|
| 26 |
+
1. **ONLY `kind='view'`.** `shares.RESHARE_KINDS` is the one spelling of that. `folder`,
|
| 27 |
+
`database` and `field` still require the owner or an admin, and the paragraph below is why
|
| 28 |
+
the `database` kind in particular was never a candidate: a `ut_*` grant's blast radius is a
|
| 29 |
+
whole table, where a view is one saved SELECTION over rows the receiver's own wall governs.
|
| 30 |
+
2. **A RE-SHARE MAY NEVER EXCEED THE RE-SHARER'S OWN ROLE**, and the strict reading ships: an
|
| 31 |
+
`edit` grantee may hand out `view` and NEVER `edit`. `shares.max_grantable_role` answers the
|
| 32 |
+
ceiling, `put_share` enforces it on the DELTA (a name arriving at `edit`, or one raised to
|
| 33 |
+
it) β never on every row of the body, because the `PUT` REPLACES and `ShareDialog.save`
|
| 34 |
+
therefore re-sends the whole list with the re-sharer's own `edit` row inside it. Conferring
|
| 35 |
+
`edit` stays the owner's alone.
|
| 36 |
+
3. **OWNERSHIP NEVER MOVES.** A caller re-sharing rather than owning passes the EXISTING owner
|
| 37 |
+
straight through (`put_share` below, stated rather than incidental).
|
| 38 |
+
|
| 39 |
+
ββ **AND R4 BOUNDED ONLY THE ROLE. TWO MORE BOUNDS ARRIVED WITH THE OWNER'S RULING OF
|
| 40 |
+
2026-08-24, BECAUSE THE GAPS WERE MEASURED ON THE SHIPPED BUILD:**
|
| 41 |
+
|
| 42 |
+
4. **A RE-SHARER MAY SHARE ONLY WITH SPECIFIC PEOPLE (D-473).** Owner: *"a re-sharer can only
|
| 43 |
+
share to specific people"*. Before this, `[fisch:edit, *:view]` from an `edit` grantee was a
|
| 44 |
+
`200` β the role ceiling could not see it, because `*` at `view` never exceeds a `view`
|
| 45 |
+
ceiling. `_audience_added` refuses an `EVERYONE` entry this caller is ADDING, and lets one
|
| 46 |
+
the OWNER already placed ride along untouched, for the same delta reason as (2).
|
| 47 |
+
5. **A RE-SHARER MAY UN-SHARE ONLY THE PEOPLE THEY SHARED TO (D-474).** Owner: *"a re-sharer
|
| 48 |
+
can only unshare the people it shared to"*. Before this, an `edit` grantee PUTting a list
|
| 49 |
+
that omitted another grantee got `200` and that person's access was gone; `PUT []` left the
|
| 50 |
+
view shared with nobody. Answering this needs a fact the registry did not store, so each
|
| 51 |
+
entry now carries `by` β who placed it β stamped by `shares.set_grants(granter=β¦)` and
|
| 52 |
+
consulted by `_unremovable`. β An entry with NO `by` (every grant predating the change) is
|
| 53 |
+
the owner's or an admin's to revoke and nobody else's: fail-closed, because from the door
|
| 54 |
+
"unstamped" and "somebody else's" are the same observation.
|
| 55 |
+
|
| 56 |
+
β **SO BOTH FEARS THE OLD SENTENCE NAMED ARE NOW ANSWERED, AND THE PARAGRAPH THAT PRICED THE
|
| 57 |
+
SECOND ONE IS GONE RATHER THAN SOFTENED.** It read *"'Widen a users-scoped view to everyone' is
|
| 58 |
+
NOT closed β an `edit` grantee may add `*` β it is CAPPED at Can view"*, and that was an accurate
|
| 59 |
+
account of R4 that the owner overruled the moment it was put in front of them. *"Grant themselves
|
| 60 |
+
ownership and lock you out"* stays closed by (3) and `set_grants`' sticky owner. A `view` grantee
|
| 61 |
+
still cannot share at all, and still gets `403`. The client greys the editor for
|
| 62 |
+
non-administrators and offers "Can edit" to anyone it greys in; that is a courtesy, and these
|
| 63 |
+
checks are the wall.
|
| 64 |
+
|
| 65 |
+
β **WHAT `GET` NOW DISCLOSES.** The grant record carries `by`, and this route returns the record
|
| 66 |
+
verbatim β so anybody who may read a view's grant list also learns who added each person, a
|
| 67 |
+
`view` grantee included. Accepted deliberately (the alternative is a second, stripped read path
|
| 68 |
+
and therefore a second answer to one question), and stated here because this file states costs
|
| 69 |
+
rather than leaving them to be discovered.
|
| 70 |
+
|
| 71 |
+
β **THE GRANT NEVER WIDENS PAST THE MODULE WALL β ON A GOVERNED MODULE.** `*` ("everyone") means
|
| 72 |
+
every account that can already open the surface: `require_session` plus the topic's own gate run
|
| 73 |
+
first, and for `customer_data` / `product_data` the receiver's own row scope and hidden-field
|
| 74 |
+
closure run BEFORE any foreign view is merged. Sharing there can only narrow-or-equal the set that
|
| 75 |
+
could already reach the data ([[aios-permissioning]]).
|
| 76 |
+
|
| 77 |
+
ββ **AND THAT SENTENCE IS FALSE FOR `kind='database'`, WHICH IS WHY IT NOW SAYS "ON A GOVERNED
|
| 78 |
+
MODULE" (W32-T26, audit S-8).** `routes_admin._PERM_MODULES` is `("customer_data","product_data")`
|
| 79 |
+
and `_clean_perms` **400s** on anything else, so **no row filter and no hidden field can even be
|
| 80 |
+
DECLARED for a `ut_*` database** β `routes_tables.py` makes zero `perm_scope` calls and passes
|
| 81 |
+
`hidden_keys=frozenset()`. There is no module wall behind a user table for a grant to be bounded
|
| 82 |
+
by: **this registry IS the wall.** So a `database` grant is ALL-OR-NOTHING β every row, every
|
| 83 |
+
column β and an `*` database grant admits every account in the tenant to all of it.
|
| 84 |
+
That is a real capability, deliberately kept; what was wrong was a docstring promising a second
|
| 85 |
+
wall that does not exist for this kind. Scoping user tables is booked, not done
|
| 86 |
+
(`waves/wave32/sharing-audit.md` S-8).
|
| 87 |
+
|
| 88 |
+
β **TWO SYSTEMS ANSWER "IS THIS SHARED", AND THEY ARE NOT THE SAME ONE (audit S-4).** THIS
|
| 89 |
+
registry decides who appears in *"Shared with me"* and who may re-share. **`table_store.is_shared`
|
| 90 |
+
β the view's own `permissions` β is what actually decides who may OPEN a view.** A grant here
|
| 91 |
+
whose object is invisible under that one is a row in a list that opens a refusal, which is what
|
| 92 |
+
made item 18 worth auditing. `_entries_or_400` closes the common cause (a name nobody has), but
|
| 93 |
+
the two vocabularies are still two.
|
| 94 |
+
"""
|
| 95 |
+
from fastapi import APIRouter, Body, Depends
|
| 96 |
+
|
| 97 |
+
import core.shares as shares
|
| 98 |
+
import core.users as users
|
| 99 |
+
from deps import Session, err, require_session
|
| 100 |
+
# β W32-T28 (C3) β the SHARE notification's topic word, imported from the module that CLASSIFIES
|
| 101 |
+
# it (`routes_alerts.notification_view`) rather than typed again here. The producer and the
|
| 102 |
+
# reader agreeing about one string is the whole difference between an Inbox row that opens the
|
| 103 |
+
# shared database and one that is quietly unclickable.
|
| 104 |
+
from routes_alerts import SHARE_TOPIC as _SHARE_TOPIC
|
| 105 |
+
|
| 106 |
+
router = APIRouter(prefix="/api/v1")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _kind_or_400(raw):
|
| 110 |
+
try:
|
| 111 |
+
return shares._check_kind(raw)
|
| 112 |
+
except ValueError as e:
|
| 113 |
+
raise err(400, "bad_kind", str(e))
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ββ ββ WAVE 32 Β· T26 (owner item 18, ruling R12) β THE WALL THIS FILE SAID IT HAD βββββββββββββ
|
| 117 |
+
#
|
| 118 |
+
# `put_share`'s comment used to justify the first-claim rule with *"reaching this route at all
|
| 119 |
+
# means passing the surface's own wall"*. **There was no such wall.** `kind` and `oid` are free
|
| 120 |
+
# strings off the URL and the only dependency was `require_session`, so any signed-in account
|
| 121 |
+
# could `PUT` a grant on an id it had never seen. Because the 403 sat behind `if rec["owner"]`,
|
| 122 |
+
# an object with no grant record skipped the check entirely and the caller was stamped OWNER β
|
| 123 |
+
# sticky, so **the real creator was then refused on their own view, permanently.** Driven, not
|
| 124 |
+
# argued: `waves/wave32/sharing-audit.md` S-1 carries the four-step transcript.
|
| 125 |
+
#
|
| 126 |
+
# β AND IT WAS SILENT ON BOTH SIDES. The claimant does not even see the object in their own
|
| 127 |
+
# "Shared with me" (`shared_with` excludes what you own), so nothing appears anywhere until the
|
| 128 |
+
# victim next opens the dialog.
|
| 129 |
+
|
| 130 |
+
#: The built-in grid topics. A view or folder lives in `{topic}_table_workspace`, and the share
|
| 131 |
+
#: route is not told which topic β so resolving one means asking each.
|
| 132 |
+
_BUILTIN_TOPICS = ("customer", "product")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _field_storage_keys(table_key):
|
| 136 |
+
"""Resolve the client-facing field topic to its durable stores and grant topic."""
|
| 137 |
+
raw = str(table_key or "").strip()
|
| 138 |
+
if raw in ("customer_data", "customer_table_workspace"):
|
| 139 |
+
return "customer_table_workspace", "customer_table_workspace", "customer_data"
|
| 140 |
+
if raw in ("product_data", "product_table_workspace"):
|
| 141 |
+
return "product_table_workspace", "product_table_workspace", "product_data"
|
| 142 |
+
if raw.startswith("ut_"):
|
| 143 |
+
bare = raw[:-len("_table_workspace")] if raw.endswith("_table_workspace") else raw
|
| 144 |
+
return f"{bare}_table_workspace", bare, bare
|
| 145 |
+
workspace = raw if raw.endswith("_table_workspace") else f"{raw}_table_workspace"
|
| 146 |
+
shared = raw[:-len("_table_workspace")] if raw.endswith("_table_workspace") else raw
|
| 147 |
+
return workspace, shared, shared
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _field_definition(session, table_key, field_key):
|
| 151 |
+
"""Return the shared/private definition and the keys used by its write paths."""
|
| 152 |
+
workspace_key, shared_key, grant_topic = _field_storage_keys(table_key)
|
| 153 |
+
try:
|
| 154 |
+
from core import shared_overlay
|
| 155 |
+
shared = (shared_overlay.fields(shared_key, st=session.runtime) or {}).get(field_key)
|
| 156 |
+
if isinstance(shared, dict):
|
| 157 |
+
return shared, True, workspace_key, shared_key, grant_topic
|
| 158 |
+
import core.table_store as table_store
|
| 159 |
+
private = (table_store.make(workspace_key, st=session.runtime)
|
| 160 |
+
.workspace(session.uname).get("fields") or {}).get(field_key)
|
| 161 |
+
if isinstance(private, dict):
|
| 162 |
+
return private, False, workspace_key, shared_key, grant_topic
|
| 163 |
+
except Exception: # noqa: BLE001
|
| 164 |
+
pass
|
| 165 |
+
return None, False, workspace_key, shared_key, grant_topic
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _field_owner(session, definition, already_shared):
|
| 169 |
+
"""Resolve the creator for the share claim wall.
|
| 170 |
+
|
| 171 |
+
A private field is already namespaced by the caller's own workspace. Older field records
|
| 172 |
+
from before the host-side creator stamp therefore remain safely claimable by that workspace
|
| 173 |
+
owner, while a shared definition with no creator stays admin-only because its storage is
|
| 174 |
+
tenant-wide and cannot identify an owner from residency alone.
|
| 175 |
+
"""
|
| 176 |
+
owner = str((definition or {}).get("createdBy") or "").strip()
|
| 177 |
+
if owner:
|
| 178 |
+
return owner
|
| 179 |
+
return str(session.uname or "").strip() if not already_shared else ""
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _topics(session):
|
| 183 |
+
"""Every topic whose workspace could hold a view or folder for this tenant.
|
| 184 |
+
|
| 185 |
+
β `all_defs`, never `all_tables` β the latter is the whole 28.6 MB row payload (~703 ms on
|
| 186 |
+
tenant #0) to answer a question about KEYS (D-185).
|
| 187 |
+
"""
|
| 188 |
+
try:
|
| 189 |
+
import core.user_tables as ut
|
| 190 |
+
return (*_BUILTIN_TOPICS, *(ut.all_defs(st=session.runtime) or {}))
|
| 191 |
+
except Exception: # noqa: BLE001
|
| 192 |
+
return _BUILTIN_TOPICS
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _owns_object(session, kind, oid):
|
| 196 |
+
"""May this caller CLAIM an object that has no grant record yet β i.e. do they own it?
|
| 197 |
+
|
| 198 |
+
β THIS GUARDS THE CLAIM, NOT THE READ, AND THAT IS DELIBERATE. Resolving a view means asking
|
| 199 |
+
each topic's workspace in turn, which is N store reads; making every share call pay that
|
| 200 |
+
would put a loop on a route the manage-access dialog opens. The dangerous path is the one
|
| 201 |
+
where a caller is about to be stamped OWNER of something nobody owns β so the resolution runs
|
| 202 |
+
exactly there, and the common path (a record exists, `may_administer` decides) is untouched.
|
| 203 |
+
"""
|
| 204 |
+
if session.admin:
|
| 205 |
+
return True
|
| 206 |
+
if kind == "field":
|
| 207 |
+
# ββ W38-T16 β A COLUMN'S OWNER IS ITS `createdBy`, WHICH THE CREATE DOOR ALREADY STAMPS
|
| 208 |
+
# (`routes_tables.patch_shared_cell`) and the DELETE door already reads as its wall (R8 /
|
| 209 |
+
# D-172: creator-or-admin). Read from the same place by all three, so a column cannot be
|
| 210 |
+
# deletable by one person and shareable by another.
|
| 211 |
+
# β THIS BRANCH IS NOT OPTIONAL AND ITS ABSENCE FAILS SILENTLY IN THE WORST DIRECTION:
|
| 212 |
+
# a brand-new column has no grant record, so `put_share` falls to this predicate β and
|
| 213 |
+
# without it the column's own creator is answered `404 no_object` on the first attempt to
|
| 214 |
+
# share the thing they just made.
|
| 215 |
+
table_key, field_key = shares.split_field_oid(oid)
|
| 216 |
+
if not table_key:
|
| 217 |
+
return False
|
| 218 |
+
defn, _shared, _workspace, _shared_key, _grant_topic = _field_definition(
|
| 219 |
+
session, table_key, field_key)
|
| 220 |
+
owner = _field_owner(session, defn, _shared)
|
| 221 |
+
return bool(defn) and owner.lower() == str(session.uname).strip().lower()
|
| 222 |
+
if kind == "database":
|
| 223 |
+
# β `may_open` is THE resolver for a user table (its own docstring says so) and already
|
| 224 |
+
# admits creator, admin, or a `database` grantee. Re-implementing "who owns a table"
|
| 225 |
+
# here would be the second definition this wave keeps finding.
|
| 226 |
+
try:
|
| 227 |
+
import core.user_tables as ut
|
| 228 |
+
return bool(ut.may_open(oid, session.uname, is_admin=session.admin,
|
| 229 |
+
st=session.runtime))
|
| 230 |
+
except Exception: # noqa: BLE001
|
| 231 |
+
return False
|
| 232 |
+
try:
|
| 233 |
+
import core.table_store as table_store
|
| 234 |
+
except Exception: # noqa: BLE001
|
| 235 |
+
return False
|
| 236 |
+
for topic in _topics(session):
|
| 237 |
+
try:
|
| 238 |
+
ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
|
| 239 |
+
hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
|
| 240 |
+
except Exception: # noqa: BLE001
|
| 241 |
+
continue
|
| 242 |
+
if hit:
|
| 243 |
+
# `find_view`/`find_folder` answer `(owner_username, β¦)`. The claim belongs to the
|
| 244 |
+
# person whose personal stratum holds it β anybody else reaching this line is
|
| 245 |
+
# exactly the case S-1 describes.
|
| 246 |
+
return str(hit[0]) == str(session.uname)
|
| 247 |
+
return False
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def _can_see_object(session, kind, oid):
|
| 251 |
+
"""May this caller READ an object's grant list β i.e. can they reach the object at all?
|
| 252 |
+
|
| 253 |
+
ββ THIS IS DELIBERATELY WIDER THAN {@link _owns_object}, AND CONFLATING THE TWO IS A
|
| 254 |
+
REGRESSION I SHIPPED AND CAUGHT. The first version of T26 guarded BOTH doors with the
|
| 255 |
+
ownership test, which reads sensibly and is wrong for the read, because **`find_view` searches
|
| 256 |
+
PERSONAL STRATA ONLY** (its own docstring says so). So a view living in alice's stratum with
|
| 257 |
+
`permissions.edit = "collaborative"` and no grant record yet β a view bob **can open and edit
|
| 258 |
+
in the grid** β answered `404` when bob opened its manage-access dialog. Measured before
|
| 259 |
+
fixing: `table_store._may_see(view, "bob") is True` while `GET /share/view/vc` said
|
| 260 |
+
`404 no_object`.
|
| 261 |
+
β THAT IS THE AUDIT'S OWN S-4 BITING THE AUDIT'S OWN FIX: two systems answer "is this shared",
|
| 262 |
+
and the wall consulted the grant registry (system A) plus stratum ownership, never the view's
|
| 263 |
+
`permissions` (system B) β which is the one that actually decides who may OPEN it.
|
| 264 |
+
β And it hides the ANSWER, not just the editor. `ViewSidebar`'s Share row is deliberately not
|
| 265 |
+
gated on edit rights because *"hiding the row from everyone else would hide the ANSWER too β
|
| 266 |
+
'who has this?' is a fair question for anyone the view was shared with"*. A 404 there tells a
|
| 267 |
+
legitimate collaborator their view does not exist.
|
| 268 |
+
|
| 269 |
+
β THE CLAIM KEEPS THE NARROW TEST. Being able to SEE an object must not let you become its
|
| 270 |
+
owner β that is S-1, and widening this predicate onto `put_share` would re-open it.
|
| 271 |
+
"""
|
| 272 |
+
if _owns_object(session, kind, oid):
|
| 273 |
+
return True
|
| 274 |
+
if kind != "view":
|
| 275 |
+
# A folder carries no per-object visibility flag of its own, and a database's `may_open`
|
| 276 |
+
# (inside `_owns_object`) already admits grantees. Nothing wider to ask.
|
| 277 |
+
# β W38-T16 β AND `field` KEEPS THIS CLOSED, DELIBERATELY. A grantee never reaches here:
|
| 278 |
+
# `get_share` tests `role is None` first and a grant answers a role, so the only caller
|
| 279 |
+
# left is an account with no relationship to the column at all. Widening it would let any
|
| 280 |
+
# signed-in session enumerate who holds which column on a database they cannot open.
|
| 281 |
+
return False
|
| 282 |
+
try:
|
| 283 |
+
import core.table_store as table_store
|
| 284 |
+
for topic in _topics(session):
|
| 285 |
+
hit = table_store.make(f"{topic}_table_workspace", st=session.runtime).find_view(oid)
|
| 286 |
+
if hit:
|
| 287 |
+
return bool(table_store._may_see(hit[1] if len(hit) > 1 else {},
|
| 288 |
+
session.uname, is_admin=session.admin))
|
| 289 |
+
except Exception: # noqa: BLE001
|
| 290 |
+
return False
|
| 291 |
+
return False
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _entries_or_400(session, entries):
|
| 295 |
+
"""Validate a grant list against the tenant's REAL, ACTIVE accounts β and refuse BY NAME.
|
| 296 |
+
|
| 297 |
+
β `core.shares._clean_entries` silently drops junk, and its docstring argues that correctly:
|
| 298 |
+
a UI mid-save must not lose the whole list to one malformed row. **But it validates the SHAPE
|
| 299 |
+
of a string and the role word β never that the user EXISTS, is ACTIVE, or is in this tenant**,
|
| 300 |
+
so a typo'd name is stored, reported as a successful save, and never reaches anybody. The
|
| 301 |
+
sharer believes the person has access. That is item 18's plain reading.
|
| 302 |
+
β The correct population is computed THREE FUNCTIONS BELOW and served to the picker
|
| 303 |
+
(`_people`). One route, two populations, and the write door was the permissive one.
|
| 304 |
+
β `*` (everyone) is not a user and is admitted deliberately β it is R10's vocabulary for
|
| 305 |
+
"every account that can already open the surface".
|
| 306 |
+
"""
|
| 307 |
+
known = {p["username"].strip().lower() for p in _people(session.tenant)}
|
| 308 |
+
unknown = []
|
| 309 |
+
for e in entries or ():
|
| 310 |
+
if not isinstance(e, dict):
|
| 311 |
+
continue
|
| 312 |
+
user = str(e.get("user") or "").strip().lower()
|
| 313 |
+
if user and user != shares.EVERYONE and user not in known:
|
| 314 |
+
unknown.append(user)
|
| 315 |
+
if unknown:
|
| 316 |
+
raise err(400, "unknown_people",
|
| 317 |
+
"no active account in this workspace is named "
|
| 318 |
+
+ ", ".join(sorted(set(unknown)))
|
| 319 |
+
+ ". Nothing was shared. Pick people from the list rather than typing a name.")
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def _named(who):
|
| 323 |
+
"""The people in a refusal, in the words the person reading it uses.
|
| 324 |
+
|
| 325 |
+
ONE spelling, used by all three re-share refusals below. `*` is never printed raw: the store's
|
| 326 |
+
wildcard is a single character, and a 403 reading *"set * to Can view"* names nothing a person
|
| 327 |
+
can find in the dialog they are looking at.
|
| 328 |
+
"""
|
| 329 |
+
return ", ".join("everyone in this workspace" if w == shares.EVERYONE else w
|
| 330 |
+
for w in sorted(who))
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def _audience_added(entries, held):
|
| 334 |
+
"""ββ D-473 β the `EVERYONE` grant this caller is ADDING, or the empty set.
|
| 335 |
+
|
| 336 |
+
OWNER RULING 2026-08-24, verbatim: *"a re-sharer can only share to specific people"*. R4's
|
| 337 |
+
ceiling bounded the ROLE a re-sharer may hand out and said nothing about the AUDIENCE, and the
|
| 338 |
+
gap was measured rather than argued: an `edit` grantee PUTting `[fisch:edit, *:view]` was
|
| 339 |
+
answered `200`, widening a two-person view to the whole tenant at `Can view`. The role check
|
| 340 |
+
could not catch it, because `*` at `view` never exceeds a `view` ceiling.
|
| 341 |
+
|
| 342 |
+
β IT IS THE ADDITION THAT IS REFUSED, NOT THE PRESENCE, and that is the same shape as the
|
| 343 |
+
role check one arm above, for the same reason: the `PUT` REPLACES, so `ShareDialog.save`
|
| 344 |
+
re-sends the WHOLE list every time. An `*` the OWNER placed rides along in every payload the
|
| 345 |
+
re-sharer is able to produce, and refusing on presence would `403` every save on a view the
|
| 346 |
+
owner had already opened to everyone β a re-sharer locked out of a list they may legitimately
|
| 347 |
+
edit, with a message about a row they did not touch.
|
| 348 |
+
|
| 349 |
+
β ROLE IS NOT CONSULTED HERE, DELIBERATELY. This answers "may this caller widen the
|
| 350 |
+
AUDIENCE", and `*` held at `view` and resubmitted at `edit` is a ROLE escalation that the
|
| 351 |
+
check above already refuses, by name. Two questions, two predicates
|
| 352 |
+
([[one-evaluator-per-question]]) β and separable is also what lets a gate disarm one of them
|
| 353 |
+
in memory and prove the other still fires.
|
| 354 |
+
|
| 355 |
+
β `_clean_entries`, NEVER THE RAW BODY: an entry with a junk role is dropped by the writer,
|
| 356 |
+
so reading the raw list would refuse a widening that was never going to be stored.
|
| 357 |
+
"""
|
| 358 |
+
if shares.EVERYONE in (held or {}):
|
| 359 |
+
return set()
|
| 360 |
+
return {e["user"] for e in shares._clean_entries(entries) if e["user"] == shares.EVERYONE}
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def _unremovable(held_rows, entries, uname):
|
| 364 |
+
"""ββ D-474 β the people this caller is dropping from the list but may NOT revoke.
|
| 365 |
+
|
| 366 |
+
OWNER RULING 2026-08-24, verbatim: *"a re-sharer can only unshare the people it shared to"*.
|
| 367 |
+
R4 bounded what a re-sharer may HAND OUT and left what they may TAKE AWAY unbounded, and both
|
| 368 |
+
halves of that were measured: an `edit` grantee PUTting a list that omits another grantee was
|
| 369 |
+
answered `200` and that person's access was gone; `PUT []` left the view shared with nobody.
|
| 370 |
+
|
| 371 |
+
ββ AN ENTRY WITH NO `by` IS NOT REMOVABLE BY A RE-SHARER β ONLY BY THE OWNER OR AN ADMIN,
|
| 372 |
+
AND THAT IS THE FAIL-CLOSED DIRECTION RATHER THAN AN OVERSIGHT. Every grant written before
|
| 373 |
+
provenance existed carries no stamp, so "no `by`" and "granted by somebody else" are
|
| 374 |
+
indistinguishable from here. Reading absence as *"nobody claims it, so anyone may take it"*
|
| 375 |
+
would hand every re-sharer the power to revoke the entire pre-existing grant set on day one
|
| 376 |
+
of this change, which is the exact capability the ruling withholds. A re-sharer must not be
|
| 377 |
+
able to revoke a grant they cannot PROVE they made [[aios-permissioning]].
|
| 378 |
+
|
| 379 |
+
β SO A RE-SHARER CANNOT REMOVE THEMSELVES EITHER, AND THAT IS STATED BECAUSE IT LOOKS LIKE A
|
| 380 |
+
BUG. Their own row was placed by the owner, so it carries the owner's `by` and lands in this
|
| 381 |
+
set. Read literally, the ruling says a re-sharer unshares only who THEY shared to, and their
|
| 382 |
+
own grant is not one of those. Leaving the view is the owner's to do, like every other
|
| 383 |
+
revocation of an owner-placed grant. β Do not carve an exception here without a ruling: the
|
| 384 |
+
carve-out is indistinguishable from "a re-sharer may revoke any row whose `by` names the
|
| 385 |
+
owner", which is the wall itself.
|
| 386 |
+
|
| 387 |
+
β `_clean_entries`, NEVER THE RAW BODY, AND THIS IS THE HOLE THAT SHAPE CLOSES. A role the
|
| 388 |
+
writer rejects is a row that will NOT be stored, so `{user: victim, role: "nonsense"}` looks
|
| 389 |
+
present in the raw payload and is a silent REVOCATION once written. Asking the same
|
| 390 |
+
normaliser the store uses is what makes "submitted" mean the same thing at both ends.
|
| 391 |
+
"""
|
| 392 |
+
me = str(uname or "").strip().lower()
|
| 393 |
+
submitted = {e["user"] for e in shares._clean_entries(entries)}
|
| 394 |
+
stuck = set()
|
| 395 |
+
for user, row in (held_rows or {}).items():
|
| 396 |
+
if user in submitted:
|
| 397 |
+
continue
|
| 398 |
+
stamp = str((row or {}).get("by") or "").strip().lower()
|
| 399 |
+
if not me or not stamp or stamp != me:
|
| 400 |
+
stuck.add(user)
|
| 401 |
+
return stuck
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
@router.get("/share/mine")
|
| 405 |
+
def my_shares(session: Session = Depends(require_session)):
|
| 406 |
+
"""Everything shared WITH me, by kind β the "Shared with me" rail section (R10).
|
| 407 |
+
|
| 408 |
+
Registered before `/share/{kind}/{oid}` so the literal path wins the match; FastAPI resolves
|
| 409 |
+
in declaration order and `mine` would otherwise be read as a `kind`, answering 400 for a URL
|
| 410 |
+
that is not malformed at all.
|
| 411 |
+
"""
|
| 412 |
+
return shares.shared_with(session.uname, st=session.runtime)
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
@router.get("/share/{kind}/{oid}")
|
| 416 |
+
def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
|
| 417 |
+
kind = _kind_or_400(kind)
|
| 418 |
+
if kind == "field":
|
| 419 |
+
table_key, field_key = shares.split_field_oid(oid)
|
| 420 |
+
if table_key and field_key:
|
| 421 |
+
oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key)
|
| 422 |
+
rec = shares.grants(kind, oid, st=session.runtime)
|
| 423 |
+
role = shares.role_for(kind, oid, session.uname, is_admin=session.admin, st=session.runtime)
|
| 424 |
+
may_admin = shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
|
| 425 |
+
st=session.runtime)
|
| 426 |
+
# W39-T29 β the dialog opens with GET before its first PUT. Until that PUT exists the grant
|
| 427 |
+
# registry has no owner to return from `may_administer`, even though the same caller may safely
|
| 428 |
+
# claim their own object through the PUT path below. Reflect that exact claim predicate here:
|
| 429 |
+
# a Member who owns an unshared View receives the people picker; a collaborator still does not.
|
| 430 |
+
if not rec["owner"] and not may_admin and _owns_object(session, kind, oid):
|
| 431 |
+
may_admin = True
|
| 432 |
+
# β W32-T26 (audit S-3) β A STRANGER LEARNS NOTHING. This route used to answer for ANY id:
|
| 433 |
+
# who owns it, everyone it is granted to, and the tenant's whole usernameβname directory β
|
| 434 |
+
# to any signed-in session, about objects it cannot open. Now a caller with no role on an
|
| 435 |
+
# object must prove they can reach it, and gets a 404 otherwise: the same answer a
|
| 436 |
+
# non-existent id gives, so the route cannot be used to probe which ids are real.
|
| 437 |
+
# β `role is None` is the cheap pre-test, so the N-topic resolution below runs only for a
|
| 438 |
+
# caller who has no relationship with the object at all.
|
| 439 |
+
if role is None and not _can_see_object(session, kind, oid):
|
| 440 |
+
raise err(404, "no_object", "no such item, or it is not shared with this account")
|
| 441 |
+
return {
|
| 442 |
+
**rec,
|
| 443 |
+
"role": role,
|
| 444 |
+
"mayAdminister": may_admin,
|
| 445 |
+
# β WAVE 21 (C1 identity fix): grant entries BIND on USERNAMES, so the picker must carry
|
| 446 |
+
# them. `assignable_people` serves bare display names because `user`-kind CELLS store
|
| 447 |
+
# display names β that list's shape cannot change without migrating cell values β so
|
| 448 |
+
# this route serves objects of its own. Existing grants that were written as lowercased
|
| 449 |
+
# display names are normalised by the wave-21 cleanup script.
|
| 450 |
+
# β W32-T26 (audit S-3) β the roster is the EDITOR's data, so it rides only for a caller
|
| 451 |
+
# who may open the editor. A read-only grantee gets the grant list (their fair question is
|
| 452 |
+
# "who else has this?") and not a directory of every account in the workspace.
|
| 453 |
+
# ββ W40-T02 / R4 β AND THAT RULE IS WHY THIS LINE NEEDED NO EDIT. `may_administer` now
|
| 454 |
+
# answers True for an `edit` grantee on a VIEW, which MOVES that account into "may open
|
| 455 |
+
# the editor" β so the picker they need arrives by the roster riding on the same flag it
|
| 456 |
+
# always did. Gating it on anything else (owner, `role == 'owner'`, a fresh predicate)
|
| 457 |
+
# would be a second answer to a question this file already answers once, and would leave
|
| 458 |
+
# the new grantee with an editor and no people to put in it. A `view` grantee is still
|
| 459 |
+
# `may_admin=False` here and still gets `[]`.
|
| 460 |
+
"people": _people(session.tenant) if may_admin else [],
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
def _people(tenant):
|
| 465 |
+
"""[{username, name}] for this tenant β same population as `assignable_people`, with the
|
| 466 |
+
BINDING identity alongside the display one."""
|
| 467 |
+
try:
|
| 468 |
+
reg = users.registry() or {}
|
| 469 |
+
except Exception:
|
| 470 |
+
return []
|
| 471 |
+
want = str(tenant or '').strip().lower()
|
| 472 |
+
out = []
|
| 473 |
+
for uname, u in reg.items():
|
| 474 |
+
if not isinstance(u, dict) or u.get('active') is False:
|
| 475 |
+
continue
|
| 476 |
+
if want and str(u.get('tenant') or 'royal-imports').strip().lower() != want:
|
| 477 |
+
continue
|
| 478 |
+
out.append({"username": str(uname), "name": str(u.get('name') or uname)})
|
| 479 |
+
return sorted(out, key=lambda p: p["name"].lower())
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
@router.put("/share/{kind}/{oid}")
|
| 483 |
+
def put_share(kind: str, oid: str, body: dict = Body(default=None),
|
| 484 |
+
session: Session = Depends(require_session)):
|
| 485 |
+
kind = _kind_or_400(kind)
|
| 486 |
+
if kind == "field":
|
| 487 |
+
table_key, field_key = shares.split_field_oid(oid)
|
| 488 |
+
if table_key and field_key:
|
| 489 |
+
oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key)
|
| 490 |
+
body = body or {}
|
| 491 |
+
rec = shares.grants(kind, oid, st=session.runtime)
|
| 492 |
+
# `claiming` is the "no owner yet, and this caller may become one" branch, hoisted to a name
|
| 493 |
+
# because TWO decisions below need it: the ceiling (a claimant is about to be the owner, so
|
| 494 |
+
# their ceiling is an owner's) and the owner written back (D3 β see `set_grants` at the end).
|
| 495 |
+
claiming = False
|
| 496 |
+
# An object with NO grant record yet has no owner β the first person to share it claims it.
|
| 497 |
+
# That is safe because reaching this route at all means passing the surface's own wall, and
|
| 498 |
+
# the alternative (refusing until somebody seeds an owner) would make a brand-new folder
|
| 499 |
+
# unshareable by the person who just made it.
|
| 500 |
+
if rec["owner"]:
|
| 501 |
+
if not shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
|
| 502 |
+
st=session.runtime):
|
| 503 |
+
# β R4 / W40-T02 β `may_administer` now also admits an `edit` grantee on a VIEW, so
|
| 504 |
+
# the population refused here is narrower than the code word `not_owner` suggests: a
|
| 505 |
+
# `view` grantee, or an account with an `edit` role on a kind outside
|
| 506 |
+
# `shares.RESHARE_KINDS`. The code string is kept because clients match on it.
|
| 507 |
+
raise err(403, "not_owner",
|
| 508 |
+
"only the owner of this item (or an administrator) can change who it is "
|
| 509 |
+
"shared with")
|
| 510 |
+
# ββ W32-T26 (audit S-1) β THE CLAIM NOW HAS A PRECONDITION. An object with no grant record
|
| 511 |
+
# is still claimed by the first person to share it β that rule is right, and refusing until
|
| 512 |
+
# somebody seeds an owner would make a brand-new folder unshareable by the person who just
|
| 513 |
+
# made it. What was missing is the half the old comment ASSERTED and the code never did: the
|
| 514 |
+
# claimant has to be able to reach the object. Without this, any signed-in account could
|
| 515 |
+
# stamp itself owner of an id it had never seen and lock the real creator out for good.
|
| 516 |
+
elif not _owns_object(session, kind, oid):
|
| 517 |
+
raise err(404, "no_object", "no such item, or it is not shared with this account")
|
| 518 |
+
else:
|
| 519 |
+
claiming = True
|
| 520 |
+
entries = body.get("entries")
|
| 521 |
+
if not isinstance(entries, list):
|
| 522 |
+
raise err(400, "bad_entries",
|
| 523 |
+
"entries must be a list of {user, role}. Send [] to un-share, which is how "
|
| 524 |
+
"revoking is expressed")
|
| 525 |
+
_entries_or_400(session, entries)
|
| 526 |
+
# ββ R4 / W40-T02 β THE CEILING. `may_administer` above now opens this door to an `edit`
|
| 527 |
+
# grantee on a VIEW, so R4's other half ("a re-share may never exceed the role the re-sharer
|
| 528 |
+
# holds") needs a check of its own: that caller may hand out `view`, and conferring `edit`
|
| 529 |
+
# stays the owner's or an administrator's.
|
| 530 |
+
#
|
| 531 |
+
# β AFTER THE ADMISSION, NEVER BEFORE, AND THAT ORDER IS A SECURITY PROPERTY. A caller with
|
| 532 |
+
# no role at all must keep receiving `404 no_object` (audit S-1/S-3: a stranger learns
|
| 533 |
+
# nothing, so this route cannot be used to probe which ids are real). A ceiling raised first
|
| 534 |
+
# would answer that caller `403` and turn the one route hardened against id-probing back into
|
| 535 |
+
# an oracle that confirms an id exists. It also runs before the `field` promotion below, so a
|
| 536 |
+
# refusal cannot leave a column promoted with no grant written.
|
| 537 |
+
#
|
| 538 |
+
# β AND IT IS THE DELTA, NOT EVERY ROW OF THE BODY β read off the shipped client, not
|
| 539 |
+
# assumed. `ShareDialog.save` PUTs the WHOLE list every time ("a body assembled from a delta
|
| 540 |
+
# would revoke everyone it failed to mention"), so the re-sharer's OWN `{user, role: "edit"}`
|
| 541 |
+
# row rides in every payload they are able to produce. Refusing per-entry would `403` the
|
| 542 |
+
# exact re-share this ticket exists to enable, and the only body that would pass is one that
|
| 543 |
+
# revokes the re-sharer. So what is refused is edit access this caller is CREATING: a name
|
| 544 |
+
# arriving at `edit`, or an existing `view` grantee raised to it. A row that already stood at
|
| 545 |
+
# `edit` was the OWNER's decision, and is not this caller's to be refused for.
|
| 546 |
+
#
|
| 547 |
+
# β A CLAIMANT IS AN OWNER. The branch above admits a Member who owns an object that has no
|
| 548 |
+
# grant record yet, and `set_grants` is about to stamp them owner β asking the registry for
|
| 549 |
+
# their role here would answer `None` (no record exists to hold one) and refuse the first
|
| 550 |
+
# `edit` grant on every newly created view. Same predicate as the door, one line apart.
|
| 551 |
+
ceiling = "edit" if claiming else shares.max_grantable_role(
|
| 552 |
+
kind, oid, session.uname, is_admin=session.admin, st=session.runtime)
|
| 553 |
+
#
|
| 554 |
+
# ββ OWNER RULING 2026-08-24 (D-473 + D-474) β AND THE CEILING IS NOW ONE OF THREE WALLS IN
|
| 555 |
+
# THIS BLOCK, NOT THE WALL. R4 bounded the ROLE a re-sharer may hand out and was silent on the
|
| 556 |
+
# other two questions a re-share asks, so both gaps shipped and both were measured on the
|
| 557 |
+
# build: an `edit` grantee could PUT `[fisch:edit, *:view]` and widen a two-person view to the
|
| 558 |
+
# whole tenant (200), and could PUT a list omitting another grantee β or `[]` β and revoke
|
| 559 |
+
# people they never granted (200). The owner's answer settles both in one sentence: *"no a
|
| 560 |
+
# re-sharer can only share to specific people and a re-sharer can only unshare the people it
|
| 561 |
+
# shared to"*.
|
| 562 |
+
#
|
| 563 |
+
# β THREE PREDICATES, THREE FUNCTIONS, ONE ORDER: role, then audience, then revocation. They
|
| 564 |
+
# are separate because they answer separate questions and because a wall that cannot be
|
| 565 |
+
# disarmed ALONE cannot be proven alone β `verify_scopes.section_reshare_bounds` patches each
|
| 566 |
+
# one in memory and requires exactly its own leg to go red, which a single fused `if` would
|
| 567 |
+
# make impossible ([[a-declared-gate-is-an-unchecked-claim]]).
|
| 568 |
+
# β ROLE AND AUDIENCE COMPOSE, AND THE ORDER DECIDES WHICH REFUSAL A PERSON READS. `*`
|
| 569 |
+
# submitted at `edit` while held at `view` is BOTH an escalation and (if unheld) a widening;
|
| 570 |
+
# the role check runs first and names the fix that is actually available to this caller
|
| 571 |
+
# ("set it to Can view"), which is the more useful of the two sentences.
|
| 572 |
+
if ceiling != "edit":
|
| 573 |
+
# The prior ROWS, not just their roles: the revocation wall needs each entry's `by`, and
|
| 574 |
+
# reading it from a second place would be a second answer to "what does the store hold".
|
| 575 |
+
held_rows = {e.get("user"): e for e in (rec["entries"] or ())
|
| 576 |
+
if isinstance(e, dict) and e.get("user")}
|
| 577 |
+
held = {u: e.get("role") for u, e in held_rows.items()}
|
| 578 |
+
noun = {"view": "view", "folder": "folder",
|
| 579 |
+
"database": "database", "field": "column"}.get(kind, "item")
|
| 580 |
+
raised = set()
|
| 581 |
+
for e in entries:
|
| 582 |
+
if not isinstance(e, dict):
|
| 583 |
+
continue
|
| 584 |
+
who = str(e.get("user") or "").strip().lower()
|
| 585 |
+
if who and str(e.get("role") or "").strip().lower() == "edit" \
|
| 586 |
+
and held.get(who) != "edit":
|
| 587 |
+
raised.add(who)
|
| 588 |
+
if raised:
|
| 589 |
+
raise err(403, "grant_exceeds_role",
|
| 590 |
+
"you can share this " + noun + " at Can view, which is as far as your own "
|
| 591 |
+
"access reaches. Only its owner (or an administrator) can give somebody "
|
| 592 |
+
"Can edit, so nothing was saved. Set " + _named(raised)
|
| 593 |
+
+ " to Can view and save again.")
|
| 594 |
+
# ββ D-473 β THE AUDIENCE. A re-sharer names PEOPLE; reaching "everyone" is the owner's.
|
| 595 |
+
if _audience_added(entries, held):
|
| 596 |
+
raise err(403, "grant_exceeds_audience",
|
| 597 |
+
"you can share this " + noun + " with specific people, which is as far as "
|
| 598 |
+
"your own access reaches. Only its owner (or an administrator) can open it "
|
| 599 |
+
"to everyone in this workspace, so nothing was saved. Remove Everyone from "
|
| 600 |
+
"the list, add the people you meant by name, and save again.")
|
| 601 |
+
# ββ D-474 β THE REVOCATION. An omission IS a revocation on a replacing PUT, so this is
|
| 602 |
+
# the only place a removal can be refused. β REFUSED WHOLE: `set_grants` has not run, so
|
| 603 |
+
# a payload carrying a legitimate addition ALONGSIDE a forbidden removal saves neither.
|
| 604 |
+
# That is deliberate and it is what the message promises ("nothing was saved") β a
|
| 605 |
+
# half-applied permission change is worse than a refused one, because the person reading
|
| 606 |
+
# the toast has no way to tell which half took.
|
| 607 |
+
stuck = _unremovable(held_rows, entries, session.uname)
|
| 608 |
+
if stuck:
|
| 609 |
+
raise err(403, "revoke_not_yours",
|
| 610 |
+
"you can remove the people you shared this " + noun + " with, and this "
|
| 611 |
+
"workspace has no record of you sharing it with " + _named(stuck)
|
| 612 |
+
+ ". Only its owner (or an administrator) can remove them, so nothing was "
|
| 613 |
+
"saved. Put them back on the list and save again.")
|
| 614 |
+
if kind == "field":
|
| 615 |
+
# A field grant is a visibility and edit wall. Promote a private custom
|
| 616 |
+
# field exactly once, then keep the requested Share field role as the
|
| 617 |
+
# authoritative override for the legacy permissions bag.
|
| 618 |
+
from core import field_permissions, shared_overlay
|
| 619 |
+
table_key, field_key = shares.split_field_oid(oid)
|
| 620 |
+
defn, already_shared, workspace_key, shared_key, grant_topic = _field_definition(
|
| 621 |
+
session, table_key, field_key)
|
| 622 |
+
if not isinstance(defn, dict):
|
| 623 |
+
raise err(404, "no_object", "no such field, or it is not shared with this account")
|
| 624 |
+
if not already_shared:
|
| 625 |
+
defn = field_permissions.promote_field(
|
| 626 |
+
workspace_key, shared_key, grant_topic,
|
| 627 |
+
session.uname, defn, st=session.runtime)
|
| 628 |
+
stamped = dict(defn)
|
| 629 |
+
stamped["shared"] = True
|
| 630 |
+
stamped["granted"] = True
|
| 631 |
+
shared_overlay.put_field(shared_key, field_key, stamped, st=session.runtime)
|
| 632 |
+
oid = shares.field_oid(grant_topic, field_key)
|
| 633 |
+
# ββ R4 / W40-T02 (D3) β OWNERSHIP NEVER MOVES ON A RE-SHARE, AND IT IS SAID HERE RATHER
|
| 634 |
+
# THAN LEFT TO FALL OUT. `set_grants`' owner is sticky, so the old `rec["owner"] or
|
| 635 |
+
# session.uname` already happened not to transfer ownership β incidentally, as a property of
|
| 636 |
+
# the callee. R4 names ownership transfer as one of the two halves of the old protection that
|
| 637 |
+
# SURVIVES the widening, and a rule that survives by accident is one the next edit deletes
|
| 638 |
+
# without noticing. So the branch is explicit: a claimant becomes the owner, and everybody
|
| 639 |
+
# else β an owner re-saving, an admin, and now an `edit` grantee re-sharing β passes the
|
| 640 |
+
# EXISTING owner straight back through. `claiming` is the same flag the admission set, so
|
| 641 |
+
# there is no second answer to "is this person taking ownership".
|
| 642 |
+
# ββ D-474 β `granter` IS THIS SESSION, ON EVERY SAVE INCLUDING THE OWNER'S. Provenance is
|
| 643 |
+
# recorded for whoever adds a person, not only for a re-sharer: an owner-placed grant carrying
|
| 644 |
+
# NO stamp is indistinguishable from a pre-provenance one, and `_unremovable` would then be
|
| 645 |
+
# deciding on the store's AGE rather than on who granted what. `set_grants` stamps only
|
| 646 |
+
# entries that are NEW to the record and never re-stamps an existing one, so an owner
|
| 647 |
+
# re-saving a list does not quietly take provenance off the re-sharer who built it.
|
| 648 |
+
# ββ THE THREE REFUSALS ABOVE WERE DECIDED AGAINST `rec`, WHICH WAS READ AT THE TOP OF
|
| 649 |
+
# THIS FUNCTION. Handing `expect` to the writer is what makes them true at the moment of the
|
| 650 |
+
# write rather than at the moment of the read: a concurrent save lands between the two, and
|
| 651 |
+
# a wave-40 adversarial probe drove a re-sharer's PUT being ACCEPTED while the grant the
|
| 652 |
+
# owner had just added disappeared. See `shares.set_grants`' own note.
|
| 653 |
+
try:
|
| 654 |
+
out = shares.set_grants(kind, oid, entries,
|
| 655 |
+
owner=session.uname if claiming else rec["owner"],
|
| 656 |
+
granter=session.uname,
|
| 657 |
+
st=session.runtime,
|
| 658 |
+
expect=rec["entries"])
|
| 659 |
+
except shares.GrantsChanged as exc:
|
| 660 |
+
raise err(409, "grants_changed", str(exc))
|
| 661 |
+
_notify_new_grantees(session, kind, oid, before=rec["entries"], after=out.get("entries") or [])
|
| 662 |
+
return out
|
| 663 |
+
|
| 664 |
+
|
| 665 |
+
def _notify_new_grantees(session, kind, oid, before, after):
|
| 666 |
+
"""ββ W32-T28 (owner item 18's last clause, contract C3) β tell the RECEIVER, in their Inbox.
|
| 667 |
+
|
| 668 |
+
Owner item 18 ends *"being shared a database notifies the receiver"*. Until now sharing was
|
| 669 |
+
silent: the grant landed in a rail section the receiver had to notice on their own, which is
|
| 670 |
+
why "I shared it with you" and "I never saw it" were both true.
|
| 671 |
+
|
| 672 |
+
β WRITTEN ON THE SHARE, NEVER POLLED. `/notifications` re-evaluates view-ALERTS on read
|
| 673 |
+
because an alert is a live question about rows; a share is an EVENT that happened once, and
|
| 674 |
+
polling for it would mean re-deriving "was this new?" on every inbox open β the diff below
|
| 675 |
+
only exists here, at the moment the set changes.
|
| 676 |
+
|
| 677 |
+
β ONLY THE NEWLY ADDED. `PUT` REPLACES the whole entry set (revoking is expressed by absence),
|
| 678 |
+
so every save re-sends everyone who was already there. Diffing against `before` is what stops
|
| 679 |
+
a rename or a role change from ringing the bell for people whose access did not change.
|
| 680 |
+
β `*` IS NOT NOTIFIED: there is no user to name, and minting one notification per account in
|
| 681 |
+
the tenant on a single click is a broadcast nobody asked for. The rail still shows it.
|
| 682 |
+
β IT NEVER RAISES. A notification that fails must not fail the share that triggered it β the
|
| 683 |
+
grant is the user's actual intent, and `core.alerts.notify` writes with `flush='async'`.
|
| 684 |
+
"""
|
| 685 |
+
try:
|
| 686 |
+
was = {e.get("user") for e in (before or ()) if isinstance(e, dict)}
|
| 687 |
+
fresh = [str(e.get("user")) for e in (after or ())
|
| 688 |
+
if isinstance(e, dict) and e.get("user") not in was
|
| 689 |
+
and e.get("user") != shares.EVERYONE]
|
| 690 |
+
if not fresh:
|
| 691 |
+
return
|
| 692 |
+
import core.alerts as alerts
|
| 693 |
+
|
| 694 |
+
label, route, view_id = _object_ref(session, kind, oid)
|
| 695 |
+
if not route:
|
| 696 |
+
# β NO ROUTE, NO NOTIFICATION β the receiver would get a row that opens nothing, and
|
| 697 |
+
# `notification_view` would have to invent a target. Silence is the honest answer
|
| 698 |
+
# here; the rail still shows the grant under "Shared with me".
|
| 699 |
+
return
|
| 700 |
+
sharer = str(session.user.get("name") or session.uname)
|
| 701 |
+
for user in fresh:
|
| 702 |
+
# β THE SHAPE IS `routes_alerts.notification_view`'s SHARE BRANCH, and the two must
|
| 703 |
+
# agree or the Inbox row is unclickable: `topic` selects the branch and `key` becomes
|
| 704 |
+
# `alertId`, which that branch reads as the id to open. Both constants are IMPORTED
|
| 705 |
+
# from there rather than typed again β one vocabulary, one owner.
|
| 706 |
+
# ββ W33-T28 (`ASK C-14`, answered) β `actor` IS THE SENDER, AND IT IS THE ONLY WAY
|
| 707 |
+
# THE INBOX CAN NAME ONE. An alert and an automation have no person behind them and
|
| 708 |
+
# are honestly named by their machine; a SHARE has a real person, and only this call
|
| 709 |
+
# site knows who. β It is passed as its OWN field rather than recovered from the
|
| 710 |
+
# `detail` prose below: a sender parsed out of "<name> shared this with you" breaks
|
| 711 |
+
# the first time the sentence is reworded, silently, in the header
|
| 712 |
+
# [[grep-output-is-not-source]]. The prose stays as the body; this is the From.
|
| 713 |
+
alerts.notify(user, label, topic=_SHARE_TOPIC, key=route, row_id=view_id,
|
| 714 |
+
detail=f"{sharer} shared this with you", actor=sharer,
|
| 715 |
+
st=session.runtime)
|
| 716 |
+
except Exception: # noqa: BLE001
|
| 717 |
+
return
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
def _object_ref(session, kind, oid):
|
| 721 |
+
"""`(label, route, view_id)` β what to CALL the shared thing, and where it OPENS.
|
| 722 |
+
|
| 723 |
+
β THE ROUTE IS RESOLVED HERE, NOT SHAPED IN THE CONSUMER, AND THE FIRST VERSION GOT IT
|
| 724 |
+
WRONG: it put the raw `oid` in the notification's key, so a shared VIEW produced
|
| 725 |
+
`target: {module: "database", id: "view_42"}` β an instruction to open a database named
|
| 726 |
+
`view_42`. It read perfectly in the payload and would have opened nothing. **A view is not
|
| 727 |
+
addressable on its own; it is a SELECTION inside a topic's grid**, so the pair is what has to
|
| 728 |
+
travel. Caught by looking at the notification the driver actually produced, not by reading
|
| 729 |
+
the code back.
|
| 730 |
+
|
| 731 |
+
β `label` never falls back to a raw id. A notification headed `ut_leads_3f2a` tells the
|
| 732 |
+
receiver nothing they can act on, and the id is already in the target.
|
| 733 |
+
β An unresolvable object answers `route=None`, and the caller then sends NOTHING rather than
|
| 734 |
+
a row that opens nowhere.
|
| 735 |
+
"""
|
| 736 |
+
try:
|
| 737 |
+
if kind == "field":
|
| 738 |
+
# ββ W38-T16 β A COLUMN IS NOT ADDRESSABLE ON ITS OWN, exactly as a view is not: it
|
| 739 |
+
# is a column INSIDE a database, so the target that travels is the DATABASE. Without
|
| 740 |
+
# this branch the function falls through to the view/folder loop, finds nothing,
|
| 741 |
+
# answers `route=None` β and `_notify_new_grantees` returns EARLY. The grant lands and
|
| 742 |
+
# the receiver is never told, which is the silent half of owner item 18 reopened one
|
| 743 |
+
# kind over.
|
| 744 |
+
from routes_alerts import route_for_topic
|
| 745 |
+
table_key, field_key = shares.split_field_oid(oid)
|
| 746 |
+
if not table_key:
|
| 747 |
+
return ("A column", None, "")
|
| 748 |
+
try:
|
| 749 |
+
defn, _shared, _workspace, shared_key, _grant_topic = _field_definition(
|
| 750 |
+
session, table_key, field_key)
|
| 751 |
+
except Exception: # noqa: BLE001
|
| 752 |
+
defn = None
|
| 753 |
+
label = str((defn or {}).get("label") or "").strip() or field_key
|
| 754 |
+
# β TWO SPELLINGS REACH THIS LINE AND ONE MAP ANSWERS BOTH. `shared_overlay` is keyed
|
| 755 |
+
# by whatever the calling door already held: a `ut_*` database uses its bare key,
|
| 756 |
+
# while a registry topic uses `<topic>_table_workspace` (`product_data.TABLE_KEY`).
|
| 757 |
+
# `route_for_topic` speaks the GRID SCOPE vocabulary (`customer`, not
|
| 758 |
+
# `customer_data`), so the suffix comes off before it is asked β rather than a second
|
| 759 |
+
# route table being written here, which is how the two come apart.
|
| 760 |
+
_WS = "_table_workspace"
|
| 761 |
+
scope = {"customer_data": "customer", "product_data": "product"}.get(table_key)
|
| 762 |
+
if scope is None:
|
| 763 |
+
scope = table_key[:-len(_WS)] if table_key.endswith(_WS) else table_key
|
| 764 |
+
return (label, route_for_topic(scope) or None, "")
|
| 765 |
+
if kind == "database":
|
| 766 |
+
import core.user_tables as ut
|
| 767 |
+
defn = (ut.all_defs(st=session.runtime) or {}).get(str(oid)) or {}
|
| 768 |
+
# A user table IS its own route key in both vocabularies (`route_for_topic`).
|
| 769 |
+
return (str(defn.get("label") or "").strip() or "A database", str(oid), "")
|
| 770 |
+
import core.table_store as table_store
|
| 771 |
+
from routes_alerts import route_for_topic
|
| 772 |
+
for topic in _topics(session):
|
| 773 |
+
ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
|
| 774 |
+
hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
|
| 775 |
+
if not hit:
|
| 776 |
+
continue
|
| 777 |
+
route = route_for_topic(topic)
|
| 778 |
+
if not route:
|
| 779 |
+
break
|
| 780 |
+
row = hit[1] if len(hit) > 1 else {}
|
| 781 |
+
name = str((row or {}).get("name") or "").strip()
|
| 782 |
+
# β Only a VIEW carries a selection. A folder is a rail grouping, so the target opens
|
| 783 |
+
# the grid and stops there rather than naming a view the receiver did not get.
|
| 784 |
+
return (name or ("A view" if kind == "view" else "A folder"),
|
| 785 |
+
route, str(oid) if kind == "view" else "")
|
| 786 |
+
except Exception: # noqa: BLE001
|
| 787 |
+
pass
|
| 788 |
+
return ({"view": "A view", "folder": "A folder",
|
| 789 |
+
"field": "A column"}.get(kind, "An item"), None, "")
|
platform/core/shares.py
CHANGED
|
@@ -1,414 +1,560 @@
|
|
| 1 |
-
"""core/shares.py β ONE grant registry for every shareable object (wave 20, owner ruling R10).
|
| 2 |
-
|
| 3 |
-
WHAT R10 ASKED FOR: folders and databases share with the **same two-role vocabulary views
|
| 4 |
-
already use** (specific users or everyone; role = view | edit), plus one manage-access editor
|
| 5 |
-
that can add or revoke people later, on any of the three.
|
| 6 |
-
|
| 7 |
-
WHY A REGISTRY RATHER THAN A FIELD ON EACH OBJECT. A view already carries its own `permissions`
|
| 8 |
-
(`core/table_store.py`) and that stays β moving it would rewrite every stored view for no gain.
|
| 9 |
-
But a FOLDER is a value inside one user's workspace blob and a DATABASE is a `user_tables`
|
| 10 |
-
definition; giving each its own grant field would put the same three-line permission decision in
|
| 11 |
-
three files owned by two sessions, which is how the three drift. One registry, one predicate,
|
| 12 |
-
three callers.
|
| 13 |
-
|
| 14 |
-
shares.set_grants(kind, oid, entries, owner=β¦, st=β¦) # replaces the whole
|
| 15 |
-
shares.grants(kind, oid, st=β¦) # -> {'owner': str, 'entries': [...]}
|
| 16 |
-
shares.role_for(kind, oid, user, is_admin=β¦, st=β¦) # -> 'owner'|'edit'|'view'|None
|
| 17 |
-
shares.max_grantable_role(kind, oid, user, β¦) # -> 'edit'|'view'|None (R4's ceiling)
|
| 18 |
-
shares.shared_with(user, kind=β¦, st=β¦) # -> [oid] this user was granted
|
| 19 |
-
|
| 20 |
-
THE ROLE VOCABULARY IS TWO WORDS AND THE DEFAULT IS THE NARROW ONE. `view` = may open and read;
|
| 21 |
-
`edit` = may also change the object's CONTENT.
|
| 22 |
-
|
| 23 |
-
ββ AND ON A VIEW, `edit` NOW ALSO MEANS "MAY RE-SHARE" β OWNER RULING R4, instruction 4 (*"Edit
|
| 24 |
-
View so a member can share a View as well, not just an admin"*), built as W40-T02. This REVERSES
|
| 25 |
-
the flat owner-or-admin rule this paragraph used to state, and it reverses it for ONE kind:
|
| 26 |
-
`RESHARE_KINDS` below is where the widening is spelled, once, and `folder` / `database` / `field`
|
| 27 |
-
are untouched β still the owner's or an admin's, for the reason given at that constant.
|
| 28 |
-
|
| 29 |
-
β WHICH HALF OF THE OLD PROTECTION SURVIVES, BECAUSE THE FEAR IT NAMED WAS REAL AND ONLY HALF OF
|
| 30 |
-
IT IS ANSWERED. The sentence here used to be *"a collaborator who could rewrite grants could grant
|
| 31 |
-
themselves sole ownership of somebody else's object, or quietly widen a users-scoped share to
|
| 32 |
-
everyone"*. Taking those one at a time:
|
| 33 |
-
* **SOLE OWNERSHIP IS BLOCKED β and now stated rather than incidental.** The owner is STICKY in
|
| 34 |
-
`set_grants` and `routes_shares.put_share` passes the EXISTING owner straight back through, so
|
| 35 |
-
a re-share cannot transfer ownership and no grantee can lock the creator out of their own view.
|
| 36 |
-
Ownership moves for nobody, by any path this registry offers.
|
| 37 |
-
* **WIDENING IS
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
#
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
#:
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
#:
|
| 74 |
-
#:
|
| 75 |
-
#:
|
| 76 |
-
#:
|
| 77 |
-
#:
|
| 78 |
-
#:
|
| 79 |
-
#: here
|
| 80 |
-
#:
|
| 81 |
-
#:
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
#:
|
| 85 |
-
#:
|
| 86 |
-
#:
|
| 87 |
-
#:
|
| 88 |
-
#:
|
| 89 |
-
#:
|
| 90 |
-
#:
|
| 91 |
-
#:
|
| 92 |
-
#:
|
| 93 |
-
#:
|
| 94 |
-
#:
|
| 95 |
-
#:
|
| 96 |
-
#:
|
| 97 |
-
#:
|
| 98 |
-
#:
|
| 99 |
-
#:
|
| 100 |
-
#:
|
| 101 |
-
#:
|
| 102 |
-
#:
|
| 103 |
-
#:
|
| 104 |
-
#:
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
#:
|
| 110 |
-
#:
|
| 111 |
-
#:
|
| 112 |
-
#:
|
| 113 |
-
#:
|
| 114 |
-
#:
|
| 115 |
-
#:
|
| 116 |
-
#:
|
| 117 |
-
#:
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
#:
|
| 125 |
-
#:
|
| 126 |
-
#:
|
| 127 |
-
#: the
|
| 128 |
-
#:
|
| 129 |
-
#:
|
| 130 |
-
#:
|
| 131 |
-
#:
|
| 132 |
-
#:
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
"
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
if not
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
`
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
if
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
"""
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
return
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""core/shares.py β ONE grant registry for every shareable object (wave 20, owner ruling R10).
|
| 2 |
+
|
| 3 |
+
WHAT R10 ASKED FOR: folders and databases share with the **same two-role vocabulary views
|
| 4 |
+
already use** (specific users or everyone; role = view | edit), plus one manage-access editor
|
| 5 |
+
that can add or revoke people later, on any of the three.
|
| 6 |
+
|
| 7 |
+
WHY A REGISTRY RATHER THAN A FIELD ON EACH OBJECT. A view already carries its own `permissions`
|
| 8 |
+
(`core/table_store.py`) and that stays β moving it would rewrite every stored view for no gain.
|
| 9 |
+
But a FOLDER is a value inside one user's workspace blob and a DATABASE is a `user_tables`
|
| 10 |
+
definition; giving each its own grant field would put the same three-line permission decision in
|
| 11 |
+
three files owned by two sessions, which is how the three drift. One registry, one predicate,
|
| 12 |
+
three callers.
|
| 13 |
+
|
| 14 |
+
shares.set_grants(kind, oid, entries, owner=β¦, granter=β¦, st=β¦) # replaces the whole set
|
| 15 |
+
shares.grants(kind, oid, st=β¦) # -> {'owner': str, 'entries': [...]}
|
| 16 |
+
shares.role_for(kind, oid, user, is_admin=β¦, st=β¦) # -> 'owner'|'edit'|'view'|None
|
| 17 |
+
shares.max_grantable_role(kind, oid, user, β¦) # -> 'edit'|'view'|None (R4's ceiling)
|
| 18 |
+
shares.shared_with(user, kind=β¦, st=β¦) # -> [oid] this user was granted
|
| 19 |
+
|
| 20 |
+
THE ROLE VOCABULARY IS TWO WORDS AND THE DEFAULT IS THE NARROW ONE. `view` = may open and read;
|
| 21 |
+
`edit` = may also change the object's CONTENT.
|
| 22 |
+
|
| 23 |
+
ββ AND ON A VIEW, `edit` NOW ALSO MEANS "MAY RE-SHARE" β OWNER RULING R4, instruction 4 (*"Edit
|
| 24 |
+
View so a member can share a View as well, not just an admin"*), built as W40-T02. This REVERSES
|
| 25 |
+
the flat owner-or-admin rule this paragraph used to state, and it reverses it for ONE kind:
|
| 26 |
+
`RESHARE_KINDS` below is where the widening is spelled, once, and `folder` / `database` / `field`
|
| 27 |
+
are untouched β still the owner's or an admin's, for the reason given at that constant.
|
| 28 |
+
|
| 29 |
+
β WHICH HALF OF THE OLD PROTECTION SURVIVES, BECAUSE THE FEAR IT NAMED WAS REAL AND ONLY HALF OF
|
| 30 |
+
IT IS ANSWERED. The sentence here used to be *"a collaborator who could rewrite grants could grant
|
| 31 |
+
themselves sole ownership of somebody else's object, or quietly widen a users-scoped share to
|
| 32 |
+
everyone"*. Taking those one at a time:
|
| 33 |
+
* **SOLE OWNERSHIP IS BLOCKED β and now stated rather than incidental.** The owner is STICKY in
|
| 34 |
+
`set_grants` and `routes_shares.put_share` passes the EXISTING owner straight back through, so
|
| 35 |
+
a re-share cannot transfer ownership and no grantee can lock the creator out of their own view.
|
| 36 |
+
Ownership moves for nobody, by any path this registry offers.
|
| 37 |
+
* **WIDENING IS NOW BLOCKED TOO β OWNER RULING 2026-08-24, D-473.** β THE SENTENCE HERE USED
|
| 38 |
+
TO PRICE THIS AS A COST R4 ACCEPTED: *"an `edit` grantee may add somebody β `*` included β at
|
| 39 |
+
`view`"*. Measured and reported as such, and the owner's answer was *"no a re-sharer can only
|
| 40 |
+
share to specific people"*. So a re-sharer may name PEOPLE and may not reach `EVERYONE`, and
|
| 41 |
+
the second half of the old fear is closed rather than merely capped. The ROLE ceiling below
|
| 42 |
+
still stands beside it β they are two different questions and both are asked.
|
| 43 |
+
* **THE ROLE CEILING SURVIVES UNCHANGED.** A re-share may never exceed the role the re-sharer
|
| 44 |
+
holds, and the STRICTER reading of that ships: an `edit` grantee may add somebody at `view`,
|
| 45 |
+
and can never mint `edit` access for anyone. Handing out `edit` stays the owner's (or an
|
| 46 |
+
admin's) alone. A `view` grantee still cannot share at all. `max_grantable_role` is that
|
| 47 |
+
ceiling, and `routes_shares.put_share` is where it bites.
|
| 48 |
+
* **AND REVOCATION IS BOUNDED BY PROVENANCE β OWNER RULING 2026-08-24, D-474.** *"a re-sharer
|
| 49 |
+
can only unshare the people it shared to"*. R4 bounded the role a re-sharer may HAND OUT and
|
| 50 |
+
said nothing about what they may TAKE AWAY, so an `edit` grantee could `PUT []` and leave the
|
| 51 |
+
view shared with nobody. Answering that needs a fact the record did not carry, which is what
|
| 52 |
+
the `by` member on each entry is (`_clean_entries` / `set_grants`' `granter`). β An entry
|
| 53 |
+
with NO `by` β every grant written before this change β is the OWNER's to revoke and nobody
|
| 54 |
+
else's: a re-sharer must never revoke a grant they cannot prove they made.
|
| 55 |
+
|
| 56 |
+
β AN UNREADABLE GRANT IS NO GRANT. Every path here fails closed β junk in the bucket, a missing
|
| 57 |
+
owner, an unknown role string all resolve to None rather than to a default that opens something.
|
| 58 |
+
[[aios-permissioning]]: no fail-open defaults, ever.
|
| 59 |
+
|
| 60 |
+
β THE BUCKET IS TENANT-SCOPED THROUGH `st`, like every other product-data write. Passing the
|
| 61 |
+
session's `TenantRuntime` is what keeps Nurilab's grants in Nurilab's store; the module default
|
| 62 |
+
(`core.store`) is tenant #0 and exists for the same reason it does everywhere else β the ~28
|
| 63 |
+
callers that predate multi-tenancy. (This is the D-5/D-16 residency shape, and this module does
|
| 64 |
+
NOT repeat their mistake: `st` is threaded from the first line rather than retrofitted.)
|
| 65 |
+
"""
|
| 66 |
+
import core.store as store
|
| 67 |
+
|
| 68 |
+
#: The store key. One bucket per tenant holds every kind's grants, because "what am I shared on"
|
| 69 |
+
#: is a question across kinds β the "Shared with me" folder (R10) is exactly that query, and
|
| 70 |
+
#: three separate buckets would make it three reads that can disagree about what a user can see.
|
| 71 |
+
SHARES_KEY = 'object_shares'
|
| 72 |
+
|
| 73 |
+
#: The shareable kinds. A CLOSED vocabulary: an unknown kind raises rather than creating a new
|
| 74 |
+
#: namespace by typo, which would silently grant nothing to nobody and read as "sharing is broken".
|
| 75 |
+
#:
|
| 76 |
+
#: ββ W38-T16 β `field` IS THE FOURTH, AND NOTHING IN THIS FILE BRANCHES ON IT. Every function
|
| 77 |
+
#: below treats `kind` as an opaque bucket key (`_check_kind / grants / set_grants / role_for /
|
| 78 |
+
#: may_see / may_edit / may_administer / shared_with / drop_objects`), so the kind's whole cost
|
| 79 |
+
#: here is this tuple member. That is the point of one registry: the new object's WALL is written
|
| 80 |
+
#: once in `core.perm_scope`, and its DOORS once in `routes_shares.py` β never a fourth
|
| 81 |
+
#: permission decision in a fourth file ([[one-evaluator-per-question]]).
|
| 82 |
+
KINDS = ('view', 'folder', 'database', 'field')
|
| 83 |
+
|
| 84 |
+
#: `*` is "everyone who can already open the surface". It is NOT "every account on the platform".
|
| 85 |
+
#: Spelled as a single character so it can never collide with a username (usernames are lower-case
|
| 86 |
+
#: and non-empty by `core/users.py`, and are checked against this explicitly below).
|
| 87 |
+
#:
|
| 88 |
+
#: ββ **AND WHAT THAT MEANS DEPENDS ON THE KIND β THE LINE THIS NOTE USED TO CARRY WAS FALSE FOR
|
| 89 |
+
#: ONE OF THE THREE** (W33-T30, `waves/wave32/sharing-audit.md` S-8). It read *"the module/table
|
| 90 |
+
#: wall runs FIRST and this never widens past it"*, flatly, and the audit's own words for that are
|
| 91 |
+
#: *"the third docstring in this audit describing a check that is not on the path"*. Corrected
|
| 92 |
+
#: here rather than deleted, because the sentence is TRUE of two kinds and the difference is the
|
| 93 |
+
#: whole point:
|
| 94 |
+
#: * `kind='view'` / `kind='folder'` on a GOVERNED module (`customer_data`, `product_data`) β
|
| 95 |
+
#: the sentence holds. `require_session` plus the topic's own gate run first, and the
|
| 96 |
+
#: receiver's row scope and hidden-field closure are applied BEFORE any foreign view is
|
| 97 |
+
#: merged, so a grant can only narrow-or-equal what that account could already reach.
|
| 98 |
+
#: * `kind='database'` on a `ut_*` table β ββ **THE SENTENCE HOLDS HERE TOO NOW, AND THAT IS
|
| 99 |
+
#: W36-T21 / OWNER RULING R6 (audit S-8, CLOSED).** It did not until wave 36, and the reason
|
| 100 |
+
#: is worth keeping: `routes_admin._clean_perms` 400'd any key outside
|
| 101 |
+
#: `("customer_data", "product_data")`, so no row filter and no hidden field could even be
|
| 102 |
+
#: DECLARED for a user table, `routes_tables.py` made zero `perm_scope` calls, and it passed
|
| 103 |
+
#: `hidden_keys=frozenset()`. A `database` grant was therefore ALL-OR-NOTHING β every row,
|
| 104 |
+
#: every column β and this registry was the only wall behind it.
|
| 105 |
+
#:
|
| 106 |
+
#: β WHAT CHANGED: `perm_scope.scoped_table` (contract C1) is the ONE door to any database's
|
| 107 |
+
#: rows. `routes_tables` applies the permanent filter before `pids` is taken and the transitive
|
| 108 |
+
#: hidden-field closure after `workspace_wire`, on EVERY `ut_*` read β the same code that walls
|
| 109 |
+
#: `customer_data` β and a door that cannot apply them REFUSES rather than serving the lot. So a
|
| 110 |
+
#: `database` grant is once again bounded by a second wall: it decides WHETHER an account reaches
|
| 111 |
+
#: the database, and C1 decides WHICH rows and columns it then sees. An `*` grant still admits
|
| 112 |
+
#: every account in the tenant, and each of them still only sees what their own wall allows.
|
| 113 |
+
#:
|
| 114 |
+
#: β `routes_shares.py`'s module docstring carries the OLD sentence at the other door and is in
|
| 115 |
+
#: no wave-36 fence β the audit's own fix was "say so at both", so one of the two is now stale.
|
| 116 |
+
#: Booked in `mailbox/C.md` (C-14) rather than edited across a fence
|
| 117 |
+
#: [[two-gates-can-assert-opposite-things]].
|
| 118 |
+
EVERYONE = '*'
|
| 119 |
+
|
| 120 |
+
ROLES = ('view', 'edit')
|
| 121 |
+
|
| 122 |
+
#: ββ THE KINDS AN `edit` GRANTEE MAY RE-SHARE β R4 / W40-T02, and it is `view` ALONE.
|
| 123 |
+
#:
|
| 124 |
+
#: R4's words are "may re-share **A VIEW**", and the widening goes no further than the ruling
|
| 125 |
+
#: names. That restraint is the whole reason this is a tuple rather than a bare `role == 'edit'`
|
| 126 |
+
#: arm inside `may_administer`: every predicate in this file treats `kind` as an opaque bucket key
|
| 127 |
+
#: (see the note on `KINDS`), so an UNGATED widening would reach `folder`, `database` and `field`
|
| 128 |
+
#: in the same line β handing an `edit` grantee on a `ut_*` DATABASE the power to re-share the
|
| 129 |
+
#: whole table. The asymmetry is one of blast radius: a view is one saved SELECTION, opened through
|
| 130 |
+
#: the receiver's own module wall and row scope; a database grant admits an account to a database.
|
| 131 |
+
#: `routes_shares.py`'s module docstring carries that argument at the door where it is enforced.
|
| 132 |
+
#:
|
| 133 |
+
#: β ONE SPELLING, DELIBERATELY. `may_administer` and `max_grantable_role` both consult this, so
|
| 134 |
+
#: "who may open the editor" and "what may they hand out" cannot come apart about the same kind.
|
| 135 |
+
RESHARE_KINDS = ('view',)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class GrantsChanged(Exception):
|
| 139 |
+
"""A grant write was decided against a record that is no longer the stored one.
|
| 140 |
+
|
| 141 |
+
Raised by `set_grants` when its `expect` does not match what is in the store at write time.
|
| 142 |
+
The door turns it into a 409 rather than a 500: it is not an error in the request, it is two
|
| 143 |
+
people editing the same sharing list at once, and the honest answer is to say so.
|
| 144 |
+
"""
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
#: ββ A FIELD's OBJECT ID IS TOPIC-QUALIFIED, AND THE SEPARATOR IS DECLARED HERE SO THERE IS ONE
|
| 148 |
+
#: SPELLING OF IT. A bare field key is NOT unique: `notes` exists on a dozen databases, and a
|
| 149 |
+
#: grant stored under it would admit a grantee to every `notes` column in the tenant at once β
|
| 150 |
+
#: the widening direction, silently, forever. `table_key` is the qualifier because it is the same
|
| 151 |
+
#: identifier `shared_overlay.bucket()` already keys the values by, so the grant and the data it
|
| 152 |
+
#: governs are named by the same string ([[one-question-two-normalizers]]).
|
| 153 |
+
#:
|
| 154 |
+
#: β A `ut_*` key and a registry topic key both match `[a-z0-9_]+` and neither can contain `:`,
|
| 155 |
+
#: so the split below is unambiguous in both directions.
|
| 156 |
+
FIELD_OID_SEP = ':'
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def field_oid(table_key, field_key):
|
| 160 |
+
"""`"<table_key>:<field_key>"` β the share id of ONE column on ONE database."""
|
| 161 |
+
table = str(table_key or '').strip()
|
| 162 |
+
field = str(field_key or '').strip()
|
| 163 |
+
if not table or not field:
|
| 164 |
+
raise ValueError('shares.field_oid: a field share names BOTH a database and a column. '
|
| 165 |
+
'A bare field key repeats across tables and would grant all of them')
|
| 166 |
+
return f'{table}{FIELD_OID_SEP}{field}'
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def split_field_oid(oid):
|
| 170 |
+
"""`(table_key, field_key)` or `(None, None)` for anything that is not a field oid.
|
| 171 |
+
|
| 172 |
+
β FAIL-CLOSED ON JUNK, like every other read here: a caller that cannot learn WHICH database
|
| 173 |
+
an id names must not fall back to "the one I happen to be looking at", which is how a grant
|
| 174 |
+
on somebody else's column would be read as a grant on this one.
|
| 175 |
+
"""
|
| 176 |
+
raw = str(oid or '')
|
| 177 |
+
table, sep, field = raw.partition(FIELD_OID_SEP)
|
| 178 |
+
if not sep or not table.strip() or not field.strip() or FIELD_OID_SEP in field:
|
| 179 |
+
return (None, None)
|
| 180 |
+
return (table.strip(), field.strip())
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _st(st):
|
| 184 |
+
return st if st is not None else store
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _check_kind(kind):
|
| 188 |
+
k = str(kind or '').strip().lower()
|
| 189 |
+
if k not in KINDS:
|
| 190 |
+
raise ValueError(f'{kind!r} is not a shareable kind. Use one of {", ".join(KINDS)}. '
|
| 191 |
+
f'This door refuses to invent a namespace from a typo.')
|
| 192 |
+
return k
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _clean_entries(entries):
|
| 196 |
+
"""Normalise + REJECT junk, returning [{'user': str, 'role': 'view'|'edit'}] β plus an
|
| 197 |
+
OPTIONAL `'by'`, present only when the record already carried one.
|
| 198 |
+
|
| 199 |
+
Silently dropping a malformed entry is right here and wrong elsewhere: the caller is a UI
|
| 200 |
+
that just listed the people it is about to grant, so a rejected row must not abort the whole
|
| 201 |
+
save β but an entry with an unknown ROLE must not be stored as something else's default
|
| 202 |
+
either. Dropped, never coerced.
|
| 203 |
+
|
| 204 |
+
ββ D-474 β `by` IS PROVENANCE: THE USERNAME OF WHOEVER PLACED THIS GRANT. Owner ruling
|
| 205 |
+
2026-08-24: *"a re-sharer can only unshare the people it shared to"*. That question has no
|
| 206 |
+
answer in a record that stores only WHO HOLDS the grant, so the record grew a third member and
|
| 207 |
+
this normaliser is where it survives the round trip (`grants` reads every stored entry back
|
| 208 |
+
through here, so a member this function drops is a member the door can never consult).
|
| 209 |
+
|
| 210 |
+
ββ AND THIS FUNCTION STAYS A PURE NORMALISER: IT NEVER INVENTS A `by`, AND IT NEVER
|
| 211 |
+
VALIDATES ONE. That is not tidiness, it is the whole security property. `set_grants` is handed
|
| 212 |
+
a caller-supplied list, so a `by` arriving HERE may be forged β and the merge inside
|
| 213 |
+
`set_grants._apply` therefore OVERWRITES it in both directions from the PRIOR record and the
|
| 214 |
+
`granter`, never from the payload. Were this function to treat a submitted `by` as
|
| 215 |
+
authoritative, a re-sharer could PUT `{user: victim, role: view, by: <themselves>}` to
|
| 216 |
+
re-stamp somebody else's grant as their own, then PUT again omitting the victim, and the wall
|
| 217 |
+
at the door would be decoration. Normalise the SHAPE here; decide the VALUE there.
|
| 218 |
+
|
| 219 |
+
β KEY ORDER IS `user, role, by` AND `by` IS ABSENT RATHER THAN `None` WHEN THERE IS NONE.
|
| 220 |
+
Absence is what keeps every pre-existing record byte-identical after this change (an added
|
| 221 |
+
`by: None` would rewrite the whole bucket on the next save, for nothing), and it is what
|
| 222 |
+
`aios-web/api/verify_field_permissions.py` reads when it compares `tuple(entry.values())`
|
| 223 |
+
against a 2-tuple.
|
| 224 |
+
"""
|
| 225 |
+
out, seen = [], set()
|
| 226 |
+
for e in entries or ():
|
| 227 |
+
if not isinstance(e, dict):
|
| 228 |
+
continue
|
| 229 |
+
user = str(e.get('user') or '').strip().lower()
|
| 230 |
+
role = str(e.get('role') or '').strip().lower()
|
| 231 |
+
if not user or role not in ROLES or user in seen:
|
| 232 |
+
continue
|
| 233 |
+
seen.add(user)
|
| 234 |
+
row = {'user': user, 'role': role}
|
| 235 |
+
by = str(e.get('by') or '').strip().lower()
|
| 236 |
+
if by:
|
| 237 |
+
row['by'] = by
|
| 238 |
+
out.append(row)
|
| 239 |
+
return out
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def grants(kind, oid, st=None):
|
| 243 |
+
"""`{'owner': str|None, 'entries': [{'user','role','by'?}]}` β never raises on a junk bucket.
|
| 244 |
+
|
| 245 |
+
ββ D-474 β `by` RIDES THE READ, because the DOOR is what has to consult it. The revocation
|
| 246 |
+
wall lives in `routes_shares.put_share` (it needs the session, which this layer does not
|
| 247 |
+
have), and a wall cannot read a member this function strips. `_clean_entries` carries it,
|
| 248 |
+
so every reader here sees it and no reader has to reach into the raw bucket.
|
| 249 |
+
|
| 250 |
+
β THE COST, STATED RATHER THAN DISCOVERED: `GET /api/v1/share/{kind}/{oid}` returns this
|
| 251 |
+
record verbatim, so anyone who may read a grant list now also learns WHO ADDED each person,
|
| 252 |
+
a `view` grantee included. That is a mild widening of *"who has this?"* into *"who let them
|
| 253 |
+
in?"*. It is accepted deliberately: the alternative is a second, provenance-stripped read
|
| 254 |
+
path, which is a second answer to one question and is how the two come apart
|
| 255 |
+
([[one-evaluator-per-question]]).
|
| 256 |
+
"""
|
| 257 |
+
kind = _check_kind(kind)
|
| 258 |
+
try:
|
| 259 |
+
bucket = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
|
| 260 |
+
rec = bucket.get(str(oid)) or {}
|
| 261 |
+
except Exception:
|
| 262 |
+
return {'owner': None, 'entries': []}
|
| 263 |
+
if not isinstance(rec, dict):
|
| 264 |
+
return {'owner': None, 'entries': []}
|
| 265 |
+
return {'owner': (str(rec.get('owner')).strip().lower() if rec.get('owner') else None),
|
| 266 |
+
'entries': _clean_entries(rec.get('entries'))}
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def set_grants(kind, oid, entries, owner=None, st=None, granter=None, expect=None):
|
| 270 |
+
"""REPLACE the grant set for one object. Returns the stored record.
|
| 271 |
+
|
| 272 |
+
β REPLACE, NOT MERGE, and that is the contract the UI needs: revoking is expressed by an
|
| 273 |
+
entry's ABSENCE. A merge-only API cannot remove anybody without a second verb, and the
|
| 274 |
+
manage-access editor R10 asks for is exactly "here is the list now".
|
| 275 |
+
|
| 276 |
+
ββ D-474 β `granter` IS WHO IS DOING THIS SAVE, AND IT IS THE ONLY SOURCE OF A NEW `by`.
|
| 277 |
+
Owner ruling 2026-08-24: *"a re-sharer can only unshare the people it shared to"*. The wall
|
| 278 |
+
is at the door (`routes_shares.put_share`), but the FACT it consults can only be recorded
|
| 279 |
+
here, at the write.
|
| 280 |
+
|
| 281 |
+
ββ AND THE STAMP IS DECIDED INSIDE `_apply`, NOT ABOVE IT, BECAUSE `_apply` IS THE ONLY
|
| 282 |
+
PLACE THE PRIOR RECORD IS VISIBLE. Two rules, and the first is the one that matters:
|
| 283 |
+
* a user ALREADY in the prior record keeps their stored `by` VERBATIM. Never re-stamped β
|
| 284 |
+
a re-save would otherwise transfer provenance to whoever saved last, so every re-sharer
|
| 285 |
+
would silently inherit the right to revoke everybody the owner had ever added, simply by
|
| 286 |
+
pressing Save. That is the ruling inverted, arriving by accident.
|
| 287 |
+
* a user who is NEW to the record takes `by = granter`, or carries NO `by` at all when no
|
| 288 |
+
granter was named.
|
| 289 |
+
|
| 290 |
+
β IN BOTH ARMS THE VALUE IS OVERWRITTEN FROM (prior, granter) AND NEVER READ OFF THE
|
| 291 |
+
SUBMITTED ENTRY. `_clean_entries` preserves a submitted `by` because a STORED one has to
|
| 292 |
+
survive the read; a caller-supplied one is untrusted input. Filling in only a MISSING `by`
|
| 293 |
+
would leave the two-step forgery open: PUT `{user: victim, by: <me>}` to claim provenance,
|
| 294 |
+
then PUT again omitting the victim.
|
| 295 |
+
|
| 296 |
+
β `granter` DEFAULTS TO `None`, AND THAT IS WHAT MAKES THIS SHAPE CHANGE SAFE. Every
|
| 297 |
+
non-door caller β `core.field_permissions` (promote + reconcile), `modules.product_data`'s
|
| 298 |
+
Image field, `core.grid_events`' cleanup on delete, the gates β replaces a whole grant set
|
| 299 |
+
with no caller identity in hand, and each keeps producing EXACTLY the record it produced
|
| 300 |
+
before this change: no `by`, no new bytes, no behaviour moved. A grant with no `by` is one
|
| 301 |
+
nobody can prove they made, and the door treats it as the owner's alone to revoke
|
| 302 |
+
(fail-closed). β A future edit that defaults this to a session, or stamps it from the entry,
|
| 303 |
+
deletes that property without touching a line the compiler can complain about.
|
| 304 |
+
|
| 305 |
+
β `_apply` MAY RUN MORE THAN ONCE. `store._update_locked` re-applies the mutation on a
|
| 306 |
+
`parent_commit` rebase, so the merge builds FRESH dicts from `clean` on every invocation
|
| 307 |
+
rather than mutating it in place; a second pass must see the same untouched input, and it
|
| 308 |
+
must be free to reclassify a user who was "new" on the first pass and is "prior" on the
|
| 309 |
+
second (exactly what a rebase against another writer's save looks like).
|
| 310 |
+
"""
|
| 311 |
+
kind = _check_kind(kind)
|
| 312 |
+
oid = str(oid)
|
| 313 |
+
clean = _clean_entries(entries)
|
| 314 |
+
owner_l = str(owner).strip().lower() if owner else None
|
| 315 |
+
granter_l = str(granter).strip().lower() if granter else None
|
| 316 |
+
|
| 317 |
+
def _apply(data):
|
| 318 |
+
by_kind = dict(data.get(kind) or {})
|
| 319 |
+
# ββ COMPARE-AND-SET, AND IT IS A SECURITY BOUNDARY RATHER THAN A TIDINESS ONE.
|
| 320 |
+
# `routes_shares.put_share` reads the record ONCE, decides against it (may this caller
|
| 321 |
+
# remove that person, raise that role, add that audience), and only then calls this. The
|
| 322 |
+
# store re-reads `prior` fresh in here, so between the decision and the write another
|
| 323 |
+
# save can land and the decision is being applied to a record it never saw.
|
| 324 |
+
#
|
| 325 |
+
# Measured by a wave-40 adversarial probe, and it needs no attacker timing: the owner and
|
| 326 |
+
# a re-sharer both have Manage Access open and both press Save. The re-sharer's PUT was
|
| 327 |
+
# ACCEPTED with 200 and the grant the owner had just added was gone, with no error on
|
| 328 |
+
# either screen. That is D-474's wall failing open in the one window where two people are
|
| 329 |
+
# actually editing the same thing.
|
| 330 |
+
#
|
| 331 |
+
# β THE LOST UPDATE PREDATES THE WALL -- REPLACE semantics plus read-modify-write have
|
| 332 |
+
# always meant last-write-wins here. What is new is that a PERMISSION decision now rests
|
| 333 |
+
# on that read. So the caller states what it decided against, and a write that would land
|
| 334 |
+
# on anything else is refused rather than merged.
|
| 335 |
+
#
|
| 336 |
+
# `expect=None` keeps every existing caller byte-identical: the server-derived callers
|
| 337 |
+
# (`field_permissions`, `product_data`, the `[]` revokes) are not deciding anything about
|
| 338 |
+
# a person and have nothing to compare.
|
| 339 |
+
if expect is not None:
|
| 340 |
+
_now = [(e['user'], e['role']) for e in _clean_entries(
|
| 341 |
+
(by_kind.get(oid) or {}).get('entries') if isinstance(by_kind.get(oid), dict)
|
| 342 |
+
else [])]
|
| 343 |
+
if _now != [(e['user'], e['role']) for e in _clean_entries(expect)]:
|
| 344 |
+
raise GrantsChanged(
|
| 345 |
+
'this item was shared with somebody else while you were editing, so nothing '
|
| 346 |
+
'was saved. Reopen the sharing panel to see who has access now, then make '
|
| 347 |
+
'your change again.')
|
| 348 |
+
prior = by_kind.get(oid) if isinstance(by_kind.get(oid), dict) else {}
|
| 349 |
+
# Read the prior entries through the SAME normaliser every other reader uses, so "was
|
| 350 |
+
# this user already here" cannot be answered one way by the store and another by the
|
| 351 |
+
# door ([[one-question-two-normalizers]]).
|
| 352 |
+
prior_by = {e['user']: e.get('by') for e in _clean_entries(prior.get('entries'))}
|
| 353 |
+
merged = []
|
| 354 |
+
for e in clean:
|
| 355 |
+
row = {'user': e['user'], 'role': e['role']}
|
| 356 |
+
stamp = prior_by[e['user']] if e['user'] in prior_by else granter_l
|
| 357 |
+
if stamp:
|
| 358 |
+
row['by'] = stamp
|
| 359 |
+
merged.append(row)
|
| 360 |
+
# The owner is STICKY: set once, and a later save that omits it must not orphan the
|
| 361 |
+
# object. An ownerless grant record cannot answer "who may re-share this", so every
|
| 362 |
+
# administer check would fail closed and the object would become unmanageable.
|
| 363 |
+
keep_owner = owner_l or (str(prior.get('owner')).strip().lower()
|
| 364 |
+
if prior.get('owner') else None)
|
| 365 |
+
if not merged and not keep_owner:
|
| 366 |
+
by_kind.pop(oid, None) # fully un-shared and unowned: leave no empty husk
|
| 367 |
+
else:
|
| 368 |
+
by_kind[oid] = {'owner': keep_owner, 'entries': merged}
|
| 369 |
+
data[kind] = by_kind
|
| 370 |
+
return data
|
| 371 |
+
|
| 372 |
+
_st(st).update(SHARES_KEY, _apply, flush='async')
|
| 373 |
+
return grants(kind, oid, st=st)
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def role_for(kind, oid, user, is_admin=False, st=None):
|
| 377 |
+
"""`'owner'` | `'edit'` | `'view'` | `None` β the caller's effective role, fail-closed.
|
| 378 |
+
|
| 379 |
+
An ADMIN reads as `'owner'`: an admin who could not administer an object could not
|
| 380 |
+
administer the tenant either, which is `table_store._may_administer`'s existing rule and is
|
| 381 |
+
kept identical here so the two cannot disagree about the same view.
|
| 382 |
+
"""
|
| 383 |
+
user = str(user or '').strip().lower()
|
| 384 |
+
if not user:
|
| 385 |
+
return None
|
| 386 |
+
rec = grants(kind, oid, st=st)
|
| 387 |
+
if is_admin or (rec['owner'] and rec['owner'] == user):
|
| 388 |
+
return 'owner'
|
| 389 |
+
best = None
|
| 390 |
+
for e in rec['entries']:
|
| 391 |
+
if e['user'] == user or e['user'] == EVERYONE:
|
| 392 |
+
# The STRONGER of the two wins when both a personal and an everyone grant exist:
|
| 393 |
+
# naming somebody explicitly is how you RAISE them above the room, so an
|
| 394 |
+
# everyone-view + alice-edit pair must leave alice editing.
|
| 395 |
+
if e['role'] == 'edit':
|
| 396 |
+
return 'edit'
|
| 397 |
+
best = best or 'view'
|
| 398 |
+
return best
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def may_see(kind, oid, user, is_admin=False, st=None):
|
| 402 |
+
return role_for(kind, oid, user, is_admin=is_admin, st=st) is not None
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def may_edit(kind, oid, user, is_admin=False, st=None):
|
| 406 |
+
return role_for(kind, oid, user, is_admin=is_admin, st=st) in ('owner', 'edit')
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def may_administer(kind, oid, user, is_admin=False, st=None):
|
| 410 |
+
"""May this caller change WHO ELSE reaches the object, and revoke them?
|
| 411 |
+
|
| 412 |
+
ββ R4 / W40-T02 β AN `edit` GRANTEE ANSWERS TRUE, ON A `RESHARE_KINDS` KIND. Owner
|
| 413 |
+
instruction 4: *"Edit View so a member can share a View as well, not just an admin"*. That
|
| 414 |
+
reverses this function's old owner-or-admin rule for exactly one kind; the module note says
|
| 415 |
+
which half of the protection survives and `RESHARE_KINDS` says why it stops at `view`.
|
| 416 |
+
|
| 417 |
+
β ADMINISTERING IS NOT GRANTING, AND THE SECOND QUESTION HAS ITS OWN PREDICATE. True here
|
| 418 |
+
means "may open the editor and rewrite the list"; it does NOT mean every role is theirs to
|
| 419 |
+
hand out. `max_grantable_role` is the ceiling, and R4's two halves are only both honoured if
|
| 420 |
+
a caller consults both ([[one-evaluator-per-question]]).
|
| 421 |
+
|
| 422 |
+
β THE GATE IS THE KIND, NOT THE ROLE ALONE. `role_for` is deliberately kind-agnostic, so
|
| 423 |
+
testing `== 'edit'` without `RESHARE_KINDS` would widen all four kinds in one line.
|
| 424 |
+
"""
|
| 425 |
+
role = role_for(kind, oid, user, is_admin=is_admin, st=st)
|
| 426 |
+
if role == 'owner':
|
| 427 |
+
return True
|
| 428 |
+
return role == 'edit' and _check_kind(kind) in RESHARE_KINDS
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def max_grantable_role(kind, oid, user, is_admin=False, st=None):
|
| 432 |
+
"""The STRONGEST role this caller may hand SOMEBODY ELSE on this object, fail-closed.
|
| 433 |
+
|
| 434 |
+
`'edit'` for the owner or an admin Β· `'view'` for an `edit` grantee on a `RESHARE_KINDS` kind
|
| 435 |
+
Β· `None` for anybody who may not administer the object at all.
|
| 436 |
+
|
| 437 |
+
ββ WHY A SECOND PREDICATE RATHER THAN A FLAG ON `may_administer` (R4 / W40-T02). R4 grants an
|
| 438 |
+
`edit` holder the right to re-share and caps it in the same breath: *"a re-share may never
|
| 439 |
+
exceed the role the re-sharer holds"*. Those are two different questions, and a single boolean
|
| 440 |
+
answering both is exactly how the cap gets dropped by the next caller that only needs the door.
|
| 441 |
+
|
| 442 |
+
β THE NARROWER OF R4's TWO READINGS SHIPS, AND ON PURPOSE. "Never exceed the role you hold"
|
| 443 |
+
reads either as *may grant up to and including `edit`* (an edit holder confers edit) or as *may
|
| 444 |
+
confer strictly less than the owner can*. This returns `'view'` β the second β because it is the
|
| 445 |
+
FAIL-CLOSED direction. A wrong `'view'` costs the owner one click to raise somebody; a wrong
|
| 446 |
+
`'edit'` lets a chain of collaborators propagate edit access the owner never approved, with
|
| 447 |
+
nothing in the store recording who widened it. W40-T02's `done-when` pins the same reading.
|
| 448 |
+
|
| 449 |
+
β THIS CAPS WHAT A CALLER GRANTS, NOT WHAT THE STORED SET ALREADY HOLDS, and the difference is
|
| 450 |
+
load-bearing. `routes_shares.put_share` enforces it against the DELTA β a name arriving at
|
| 451 |
+
`edit`, or an existing `view` grantee raised to it β never against every row of a body, because
|
| 452 |
+
the PUT REPLACES and the client therefore re-sends the whole list, the re-sharer's own `edit`
|
| 453 |
+
row included. The evidence for that is at the call site, where the body is.
|
| 454 |
+
|
| 455 |
+
ββ AND IT IS ONE OF THREE BOUNDS ON A RE-SHARE, NOT THE BOUND. Owner ruling 2026-08-24 added
|
| 456 |
+
two more, and both live at the door because both need the SESSION: `_audience_added` (D-473,
|
| 457 |
+
a re-sharer may name people and may not reach `EVERYONE`) and `_unremovable` (D-474, a
|
| 458 |
+
re-sharer may revoke only what their own `by` stamp says they granted). β A reader who takes
|
| 459 |
+
this function for the whole ceiling will widen the other two by leaving them alone β which is
|
| 460 |
+
exactly how R4 shipped with an audience hole and a revocation hole under a docstring that
|
| 461 |
+
read like a complete account of the limits.
|
| 462 |
+
"""
|
| 463 |
+
role = role_for(kind, oid, user, is_admin=is_admin, st=st)
|
| 464 |
+
if role == 'owner':
|
| 465 |
+
return 'edit'
|
| 466 |
+
if role == 'edit' and _check_kind(kind) in RESHARE_KINDS:
|
| 467 |
+
return 'view'
|
| 468 |
+
return None
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def shared_with(user, kind=None, st=None):
|
| 472 |
+
"""Every object id this user has been granted (excluding what they own).
|
| 473 |
+
|
| 474 |
+
This is the "Shared with me" query (R10). It EXCLUDES owned objects deliberately: a folder
|
| 475 |
+
you made is not something shared *with* you, and listing it there would make the system
|
| 476 |
+
folder a duplicate of the rail above it.
|
| 477 |
+
"""
|
| 478 |
+
user = str(user or '').strip().lower()
|
| 479 |
+
if not user:
|
| 480 |
+
return {}
|
| 481 |
+
try:
|
| 482 |
+
data = _st(st).get(SHARES_KEY) or {}
|
| 483 |
+
except Exception:
|
| 484 |
+
return {}
|
| 485 |
+
out = {}
|
| 486 |
+
for k in ([_check_kind(kind)] if kind else KINDS):
|
| 487 |
+
hits = []
|
| 488 |
+
for oid, rec in (data.get(k) or {}).items():
|
| 489 |
+
if not isinstance(rec, dict):
|
| 490 |
+
continue
|
| 491 |
+
owner = str(rec.get('owner') or '').strip().lower()
|
| 492 |
+
if owner == user:
|
| 493 |
+
continue
|
| 494 |
+
for e in _clean_entries(rec.get('entries')):
|
| 495 |
+
if e['user'] in (user, EVERYONE):
|
| 496 |
+
hits.append(str(oid))
|
| 497 |
+
break
|
| 498 |
+
out[k] = sorted(hits)
|
| 499 |
+
return out if kind is None else {_check_kind(kind): out[_check_kind(kind)]}
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def granted_oids(kind, st=None):
|
| 503 |
+
"""Every object id of one `kind` that carries AT LEAST ONE grant entry β in ONE bucket read.
|
| 504 |
+
|
| 505 |
+
β W40-T01 / OWNER INSTRUCTION 2 ("a shared View must show the shared icon in EVERY account,
|
| 506 |
+
not only the recipient's"). `shared_with` answers *what was granted TO me* and deliberately
|
| 507 |
+
EXCLUDES what the caller owns β so it structurally cannot answer the other question a share
|
| 508 |
+
mark asks: *does this object have grants at all*. That question has no viewer in it, which is
|
| 509 |
+
why this is a separate function rather than a flag bolted onto `shared_with`.
|
| 510 |
+
|
| 511 |
+
β ONE READ, NOT N β and that is the whole reason this exists rather than a loop at the caller.
|
| 512 |
+
The obvious spelling is `grants(kind, oid)` per view, and `grants` re-reads the WHOLE bucket
|
| 513 |
+
on every call; a busy rail carries dozens of views, so painting one icon would cost dozens of
|
| 514 |
+
full reads of the tenant's entire grant registry. This reads the bucket once and hands back a
|
| 515 |
+
set the caller tests in O(1).
|
| 516 |
+
|
| 517 |
+
β AN ENTRY IS WHAT COUNTS, NOT A RECORD. `set_grants` keeps an owner-only HUSK after a full
|
| 518 |
+
revoke β the owner is sticky, deliberately, see its own note β so testing for the record's
|
| 519 |
+
mere EXISTENCE would leave the mark lit forever after the last person was removed. That is
|
| 520 |
+
exactly the revoke case, so it is the difference between this being right and being decorative
|
| 521 |
+
noise. `_clean_entries` is the same normaliser every other read here uses, so a junk entry
|
| 522 |
+
cannot mark an object either.
|
| 523 |
+
|
| 524 |
+
β FAIL-CLOSED like the rest of this module: an unreadable bucket answers the EMPTY set β no
|
| 525 |
+
marks β never a default that claims something is shared. An unknown KIND still RAISES, exactly
|
| 526 |
+
as `grants` / `shared_with` / `drop_objects` do: that is a typo in a caller, not junk in the
|
| 527 |
+
store, and swallowing it into "nothing is shared" would hide a caller that never works.
|
| 528 |
+
"""
|
| 529 |
+
kind = _check_kind(kind)
|
| 530 |
+
try:
|
| 531 |
+
by_kind = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
|
| 532 |
+
rows = list(by_kind.items())
|
| 533 |
+
except Exception:
|
| 534 |
+
return set()
|
| 535 |
+
return {str(oid) for oid, rec in rows
|
| 536 |
+
if isinstance(rec, dict) and _clean_entries(rec.get('entries'))}
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
def drop_objects(pairs, st=None):
|
| 540 |
+
"""Remove whole grant RECORDS, owner husk included β wave 21, item 6a (C3).
|
| 541 |
+
|
| 542 |
+
A deleted object's grants must die with it: `shared_with` would otherwise serve ghost ids
|
| 543 |
+
into every receiver's "Shared with me" forever, and the ghost would 404 on open. One
|
| 544 |
+
transaction for the whole sweep β a table delete drops its database grant plus a view
|
| 545 |
+
grant per view that lived in its bucket."""
|
| 546 |
+
want = {}
|
| 547 |
+
for kind, oid in pairs or ():
|
| 548 |
+
want.setdefault(_check_kind(kind), set()).add(str(oid))
|
| 549 |
+
if not want:
|
| 550 |
+
return
|
| 551 |
+
|
| 552 |
+
def _apply(data):
|
| 553 |
+
for kind, oids in want.items():
|
| 554 |
+
by_kind = data.get(kind)
|
| 555 |
+
if isinstance(by_kind, dict):
|
| 556 |
+
for oid in oids:
|
| 557 |
+
by_kind.pop(oid, None)
|
| 558 |
+
return data
|
| 559 |
+
|
| 560 |
+
_st(st).update(SHARES_KEY, _apply, flush='async')
|