File size: 12,068 Bytes
ea2c336
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f546440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea2c336
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ef68ae0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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
"""core/shares.py β€” ONE grant registry for every shareable object (wave 20, owner ruling R10).

WHAT R10 ASKED FOR: folders and databases share with the **same two-role vocabulary views
already use** (specific users or everyone; role = view | edit), plus one manage-access editor
that can add or revoke people later, on any of the three.

WHY A REGISTRY RATHER THAN A FIELD ON EACH OBJECT. A view already carries its own `permissions`
(`core/table_store.py`) and that stays β€” moving it would rewrite every stored view for no gain.
But a FOLDER is a value inside one user's workspace blob and a DATABASE is a `user_tables`
definition; giving each its own grant field would put the same three-line permission decision in
three files owned by two sessions, which is how the three drift. One registry, one predicate,
three callers.

    shares.set_grants(kind, oid, entries, owner=…, st=…)   # replaces the whole grant set
    shares.grants(kind, oid, st=…)                         # -> {'owner': str, 'entries': [...]}
    shares.role_for(kind, oid, user, is_admin=…, st=…)     # -> 'owner'|'edit'|'view'|None
    shares.shared_with(user, kind=…, st=…)                 # -> [oid] this user was granted

THE ROLE VOCABULARY IS TWO WORDS AND THE DEFAULT IS THE NARROW ONE. `view` = may open and read;
`edit` = may also change the object's CONTENT. Neither ever means "may re-share": changing grants
is the OWNER's (or an admin's), which is `table_store._may_administer`'s existing rule promoted to
every kind. A collaborator who could rewrite grants could grant themselves sole ownership of
somebody else's object, or quietly widen a users-scoped share to everyone.

β›” AN UNREADABLE GRANT IS NO GRANT. Every path here fails closed β€” junk in the bucket, a missing
owner, an unknown role string all resolve to None rather than to a default that opens something.
[[aios-permissioning]]: no fail-open defaults, ever.

⚠ THE BUCKET IS TENANT-SCOPED THROUGH `st`, like every other product-data write. Passing the
session's `TenantRuntime` is what keeps Nurilab's grants in Nurilab's store; the module default
(`core.store`) is tenant #0 and exists for the same reason it does everywhere else β€” the ~28
callers that predate multi-tenancy. (This is the D-5/D-16 residency shape, and this module does
NOT repeat their mistake: `st` is threaded from the first line rather than retrofitted.)
"""
import core.store as store

#: The store key. One bucket per tenant holds every kind's grants, because "what am I shared on"
#: is a question across kinds β€” the "Shared with me" folder (R10) is exactly that query, and
#: three separate buckets would make it three reads that can disagree about what a user can see.
SHARES_KEY = 'object_shares'

#: The shareable kinds. A CLOSED vocabulary: an unknown kind raises rather than creating a new
#: namespace by typo, which would silently grant nothing to nobody and read as "sharing is broken".
KINDS = ('view', 'folder', 'database')

#: `*` is "everyone who can already open the surface". It is NOT "every account on the platform".
#: Spelled as a single character so it can never collide with a username (usernames are lower-case
#: and non-empty by `core/users.py`, and are checked against this explicitly below).
#:
#: β›”β›” **AND WHAT THAT MEANS DEPENDS ON THE KIND β€” THE LINE THIS NOTE USED TO CARRY WAS FALSE FOR
#: ONE OF THE THREE** (W33-T30, `waves/wave32/sharing-audit.md` S-8). It read *"the module/table
#: wall runs FIRST and this never widens past it"*, flatly, and the audit's own words for that are
#: *"the third docstring in this audit describing a check that is not on the path"*. Corrected
#: here rather than deleted, because the sentence is TRUE of two kinds and the difference is the
#: whole point:
#:   * `kind='view'` / `kind='folder'` on a GOVERNED module (`customer_data`, `product_data`) β€”
#:     the sentence holds. `require_session` plus the topic's own gate run first, and the
#:     receiver's row scope and hidden-field closure are applied BEFORE any foreign view is
#:     merged, so a grant can only narrow-or-equal what that account could already reach.
#:   * `kind='database'` on a `ut_*` table β€” **THE SENTENCE IS FALSE AND THIS REGISTRY IS THE
#:     ONLY WALL.** `routes_admin._PERM_MODULES` is `("customer_data", "product_data")` and
#:     `_clean_perms` 400s anything else, so no row filter and no hidden field can even be
#:     DECLARED for a user table; `routes_tables.py` makes zero `perm_scope` calls and passes
#:     `hidden_keys=frozenset()`. A `database` grant is therefore ALL-OR-NOTHING β€” every row,
#:     every column β€” and an `*` database grant admits every account in the tenant to all of it.
#:
#: ⚠ THAT IS A REAL CAPABILITY, DELIBERATELY KEPT, not a hole to plug in passing. What was wrong
#: was a docstring promising a second wall that does not exist for this kind; scoping user tables
#: is booked (S-8), not done. `routes_shares.py`'s module docstring says the same thing at the
#: OTHER door β€” the audit's fix is "say so at both", and one door saying it is how the next reader
#: gets the confident half [[one-question-two-normalizers]].
EVERYONE = '*'

ROLES = ('view', 'edit')


def _st(st):
    return st if st is not None else store


def _check_kind(kind):
    k = str(kind or '').strip().lower()
    if k not in KINDS:
        raise ValueError(f'{kind!r} is not a shareable kind. Use one of {", ".join(KINDS)} β€” '
                         f'refusing to invent a namespace from a typo.')
    return k


def _clean_entries(entries):
    """Normalise + REJECT junk, returning [{'user': str, 'role': 'view'|'edit'}].

    Silently dropping a malformed entry is right here and wrong elsewhere: the caller is a UI
    that just listed the people it is about to grant, so a rejected row must not abort the whole
    save β€” but an entry with an unknown ROLE must not be stored as something else's default
    either. Dropped, never coerced.
    """
    out, seen = [], set()
    for e in entries or ():
        if not isinstance(e, dict):
            continue
        user = str(e.get('user') or '').strip().lower()
        role = str(e.get('role') or '').strip().lower()
        if not user or role not in ROLES or user in seen:
            continue
        seen.add(user)
        out.append({'user': user, 'role': role})
    return out


def grants(kind, oid, st=None):
    """`{'owner': str|None, 'entries': [{'user','role'}]}` β€” never raises on a junk bucket."""
    kind = _check_kind(kind)
    try:
        bucket = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
        rec = bucket.get(str(oid)) or {}
    except Exception:
        return {'owner': None, 'entries': []}
    if not isinstance(rec, dict):
        return {'owner': None, 'entries': []}
    return {'owner': (str(rec.get('owner')).strip().lower() if rec.get('owner') else None),
            'entries': _clean_entries(rec.get('entries'))}


def set_grants(kind, oid, entries, owner=None, st=None):
    """REPLACE the grant set for one object. Returns the stored record.

    ⚠ REPLACE, NOT MERGE, and that is the contract the UI needs: revoking is expressed by an
    entry's ABSENCE. A merge-only API cannot remove anybody without a second verb, and the
    manage-access editor R10 asks for is exactly "here is the list now".
    """
    kind = _check_kind(kind)
    oid = str(oid)
    clean = _clean_entries(entries)
    owner_l = str(owner).strip().lower() if owner else None

    def _apply(data):
        by_kind = dict(data.get(kind) or {})
        prior = by_kind.get(oid) if isinstance(by_kind.get(oid), dict) else {}
        # The owner is STICKY: set once, and a later save that omits it must not orphan the
        # object. An ownerless grant record cannot answer "who may re-share this", so every
        # administer check would fail closed and the object would become unmanageable.
        keep_owner = owner_l or (str(prior.get('owner')).strip().lower()
                                 if prior.get('owner') else None)
        if not clean and not keep_owner:
            by_kind.pop(oid, None)          # fully un-shared and unowned: leave no empty husk
        else:
            by_kind[oid] = {'owner': keep_owner, 'entries': clean}
        data[kind] = by_kind
        return data

    _st(st).update(SHARES_KEY, _apply, flush='async')
    return grants(kind, oid, st=st)


def role_for(kind, oid, user, is_admin=False, st=None):
    """`'owner'` | `'edit'` | `'view'` | `None` β€” the caller's effective role, fail-closed.

    An ADMIN reads as `'owner'`: an admin who could not administer an object could not
    administer the tenant either, which is `table_store._may_administer`'s existing rule and is
    kept identical here so the two cannot disagree about the same view.
    """
    user = str(user or '').strip().lower()
    if not user:
        return None
    rec = grants(kind, oid, st=st)
    if is_admin or (rec['owner'] and rec['owner'] == user):
        return 'owner'
    best = None
    for e in rec['entries']:
        if e['user'] == user or e['user'] == EVERYONE:
            # The STRONGER of the two wins when both a personal and an everyone grant exist:
            # naming somebody explicitly is how you RAISE them above the room, so an
            # everyone-view + alice-edit pair must leave alice editing.
            if e['role'] == 'edit':
                return 'edit'
            best = best or 'view'
    return best


def may_see(kind, oid, user, is_admin=False, st=None):
    return role_for(kind, oid, user, is_admin=is_admin, st=st) is not None


def may_edit(kind, oid, user, is_admin=False, st=None):
    return role_for(kind, oid, user, is_admin=is_admin, st=st) in ('owner', 'edit')


def may_administer(kind, oid, user, is_admin=False, st=None):
    """Only the owner or an admin may change grants or delete. See the module note on why this
    is deliberately narrower than `may_edit`."""
    return role_for(kind, oid, user, is_admin=is_admin, st=st) == 'owner'


def shared_with(user, kind=None, st=None):
    """Every object id this user has been granted (excluding what they own).

    This is the "Shared with me" query (R10). It EXCLUDES owned objects deliberately: a folder
    you made is not something shared *with* you, and listing it there would make the system
    folder a duplicate of the rail above it.
    """
    user = str(user or '').strip().lower()
    if not user:
        return {}
    try:
        data = _st(st).get(SHARES_KEY) or {}
    except Exception:
        return {}
    out = {}
    for k in ([_check_kind(kind)] if kind else KINDS):
        hits = []
        for oid, rec in (data.get(k) or {}).items():
            if not isinstance(rec, dict):
                continue
            owner = str(rec.get('owner') or '').strip().lower()
            if owner == user:
                continue
            for e in _clean_entries(rec.get('entries')):
                if e['user'] in (user, EVERYONE):
                    hits.append(str(oid))
                    break
        out[k] = sorted(hits)
    return out if kind is None else {_check_kind(kind): out[_check_kind(kind)]}


def drop_objects(pairs, st=None):
    """Remove whole grant RECORDS, owner husk included β€” wave 21, item 6a (C3).

    A deleted object's grants must die with it: `shared_with` would otherwise serve ghost ids
    into every receiver's "Shared with me" forever, and the ghost would 404 on open. One
    transaction for the whole sweep β€” a table delete drops its database grant plus a view
    grant per view that lived in its bucket."""
    want = {}
    for kind, oid in pairs or ():
        want.setdefault(_check_kind(kind), set()).add(str(oid))
    if not want:
        return

    def _apply(data):
        for kind, oids in want.items():
            by_kind = data.get(kind)
            if isinstance(by_kind, dict):
                for oid in oids:
                    by_kind.pop(oid, None)
        return data

    _st(st).update(SHARES_KEY, _apply, flush='async')