icedcoffee-sync 0.1.0.dev5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,379 @@
1
+ """Provision Frappe roles, permissions, and users -- generically.
2
+
3
+ The client-side submit guard (`SiteConfig.may_submit`) is a courtesy any
4
+ caller can bypass; the boundary that holds is the API key's role permissions
5
+ on the server. This module builds that boundary -- a role whose flags never
6
+ include submit/cancel/amend, a user holding only that role -- and probes the
7
+ server to prove it. Callers supply doctypes, flags and names as policy.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import dataclasses
13
+ from typing import Any, Protocol
14
+
15
+ from icedcoffee_sync.frappe.client import Client, SiteConfig
16
+ from icedcoffee_sync.frappe.errors import DoesNotExistError, FrappeError, PermissionError_, ValidationError
17
+
18
+ # The Custom DocPerm flags a grant may request (Frappe version-15
19
+ # custom_docperm.json). `if_owner` and `select` are deliberately absent: see
20
+ # _ALWAYS_RESET_TO_ZERO.
21
+ PTYPES: frozenset[str] = frozenset({
22
+ "read",
23
+ "write",
24
+ "create",
25
+ "delete",
26
+ "submit",
27
+ "cancel",
28
+ "amend",
29
+ "report",
30
+ "export",
31
+ "import",
32
+ "print",
33
+ "email",
34
+ "share",
35
+ })
36
+
37
+ # Flags `grant` always writes as "0": they change what a role's other flags
38
+ # mean (`if_owner=1` narrows every permission to documents the credential
39
+ # created), so a hand edit that set one must not survive a reconcile.
40
+ _ALWAYS_RESET_TO_ZERO: frozenset[str] = frozenset({"if_owner", "select"})
41
+
42
+ # Whitelisted server methods (Frappe version-15). The permission manager's
43
+ # methods and generate_keys require System Manager; generate_keys keeps an
44
+ # existing api_key and rotates api_secret on every call.
45
+ _PERMISSION_MANAGER = "frappe.core.page.permission_manager.permission_manager"
46
+ _GENERATE_KEYS = "frappe.core.doctype.user.user.generate_keys"
47
+ # frappe.client.has_permission(doctype, docname, perm_type) -> {"has_permission": bool}
48
+ _HAS_PERMISSION = "frappe.client.has_permission"
49
+
50
+ # The probe inserts this without its mandatory `accounts` rows, so even if
51
+ # permission passed, mandatory validation would stop anything posting.
52
+ PROBE_DOCTYPE = "Journal Entry"
53
+
54
+
55
+ @dataclasses.dataclass(frozen=True)
56
+ class PermissionGrant:
57
+ """The flags to turn on for one doctype at one permission level; `grant` writes every other flag as "0"."""
58
+
59
+ doctype: str
60
+ ptypes: frozenset[str]
61
+ permlevel: int = 0
62
+
63
+ def __post_init__(self) -> None:
64
+ unknown = sorted(self.ptypes - PTYPES)
65
+ if unknown:
66
+ raise ValueError(f"not a managed permission type: {', '.join(unknown)}; PTYPES is {sorted(PTYPES)}")
67
+ if self.permlevel < 0:
68
+ raise ValueError(f"permlevel must be 0 or more, not {self.permlevel}")
69
+
70
+
71
+ class _AccessClient(Protocol):
72
+ """The slice of `Client` this module needs. Keeps fakes honest in tests."""
73
+
74
+ def get(self, doctype: str, name: str) -> Any: ...
75
+ def insert(self, doctype: str, doc: dict[str, Any]) -> Any: ...
76
+ def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
77
+ def call(self, dotted_path: str, params: dict[str, Any] | None = None) -> Any: ...
78
+
79
+
80
+ @dataclasses.dataclass(frozen=True)
81
+ class RoleChange:
82
+ """What `ensure_role` did to reach the requested role, `desk_access`, and `disabled`."""
83
+
84
+ created: bool
85
+ desk_access_repaired: bool
86
+ disabled_repaired: bool = False
87
+
88
+
89
+ def ensure_role(client: _AccessClient, role_name: str, *, desk_access: bool = False) -> RoleChange:
90
+ """Create the role, or repair an existing one's `desk_access` and `disabled`.
91
+
92
+ A hand-disabled or desk-enabled role is drift, not policy, so a re-run
93
+ repairs it the way roles and grants are reconciled.
94
+ """
95
+ try:
96
+ existing = client.get("Role", role_name)
97
+ except DoesNotExistError:
98
+ client.insert("Role", {"role_name": role_name, "desk_access": 1 if desk_access else 0})
99
+ return RoleChange(created=True, desk_access_repaired=False)
100
+
101
+ updates: dict[str, Any] = {}
102
+ desk_access_repaired = bool(existing.get("desk_access")) != desk_access
103
+ if desk_access_repaired:
104
+ updates["desk_access"] = 1 if desk_access else 0
105
+ disabled_repaired = bool(existing.get("disabled"))
106
+ if disabled_repaired:
107
+ updates["disabled"] = 0
108
+ if updates:
109
+ client.update("Role", role_name, updates)
110
+ return RoleChange(created=False, desk_access_repaired=desk_access_repaired, disabled_repaired=disabled_repaired)
111
+
112
+
113
+ def grant(client: _AccessClient, role: str, permission_grant: PermissionGrant) -> list[tuple[str, str]]:
114
+ """Write every managed flag for `role` on the doctype, "1" or "0", explicitly.
115
+
116
+ Explicit zeros are the point: a flag left unmentioned would depend on a
117
+ default this module does not control. Returns the (ptype, value) pairs sent.
118
+ """
119
+ # `add` is a no-op when the (doctype, role, permlevel) row already exists.
120
+ client.call(
121
+ f"{_PERMISSION_MANAGER}.add",
122
+ {"parent": permission_grant.doctype, "role": role, "permlevel": permission_grant.permlevel},
123
+ )
124
+ sent: list[tuple[str, str]] = []
125
+ for ptype in sorted(PTYPES | _ALWAYS_RESET_TO_ZERO):
126
+ value = "1" if ptype in permission_grant.ptypes else "0"
127
+ client.call(
128
+ f"{_PERMISSION_MANAGER}.update",
129
+ {
130
+ "doctype": permission_grant.doctype,
131
+ "role": role,
132
+ "permlevel": permission_grant.permlevel,
133
+ "ptype": ptype,
134
+ "value": value,
135
+ },
136
+ )
137
+ sent.append((ptype, value))
138
+ return sent
139
+
140
+
141
+ def revoke_grants_except(client: _AccessClient, role: str, keep: frozenset[tuple[str, int]]) -> list[str]:
142
+ """Revoke every permission row `role` holds except rows whose (doctype, permlevel) is in `keep`.
143
+
144
+ Returns a label per revoked row.
145
+ """
146
+ rows = client.call(f"{_PERMISSION_MANAGER}.get_permissions", {"role": role})
147
+ revoked: list[str] = []
148
+ for row in rows:
149
+ doctype = row["parent"]
150
+ permlevel = row["permlevel"]
151
+ if (doctype, permlevel) in keep:
152
+ continue
153
+ label = doctype if permlevel == 0 else f"{doctype} (permlevel {permlevel})"
154
+ # Frappe refuses to remove a doctype's last permission rule; that
155
+ # needs a person, so it is re-raised naming the row, not swallowed.
156
+ try:
157
+ client.call(
158
+ f"{_PERMISSION_MANAGER}.remove",
159
+ {"doctype": doctype, "role": role, "permlevel": permlevel},
160
+ )
161
+ except FrappeError as exc:
162
+ raise type(exc)(
163
+ f"could not revoke {label!r} from {role!r}: {exc.message}",
164
+ error_type=exc.error_type,
165
+ status=exc.status,
166
+ messages=exc.messages,
167
+ server_traceback=exc.server_traceback,
168
+ ) from exc
169
+ revoked.append(label)
170
+ return revoked
171
+
172
+
173
+ @dataclasses.dataclass(frozen=True)
174
+ class UserChange:
175
+ """What `ensure_user` did to reach the requested role set."""
176
+
177
+ created: bool
178
+ added_roles: frozenset[str]
179
+ removed_roles: frozenset[str]
180
+ enabled_repaired: bool = False
181
+
182
+
183
+ def ensure_user(client: _AccessClient, email: str, *, first_name: str, roles: frozenset[str]) -> UserChange:
184
+ """Create the user, or reconcile an existing one's roles and `enabled` to policy.
185
+
186
+ The role set is made exactly `roles`, not merged: a role added by hand is
187
+ a privilege escalation riding on this key. A disabled user is drift for
188
+ the same reason and is re-enabled.
189
+ """
190
+ try:
191
+ existing = client.get("User", email)
192
+ except DoesNotExistError:
193
+ client.insert(
194
+ "User",
195
+ {
196
+ "email": email,
197
+ "first_name": first_name,
198
+ "user_type": "System User",
199
+ "send_welcome_email": 0,
200
+ "enabled": 1,
201
+ "roles": [{"role": role} for role in sorted(roles)],
202
+ },
203
+ )
204
+ return UserChange(created=True, added_roles=frozenset(roles), removed_roles=frozenset())
205
+
206
+ current = frozenset(row["role"] for row in existing.get("roles", []))
207
+ added = roles - current
208
+ removed = current - roles
209
+ # An absent field means enabled (Frappe's default), not disabled.
210
+ enabled_repaired = not existing.get("enabled", 1)
211
+ updates: dict[str, Any] = {}
212
+ if added or removed:
213
+ updates["roles"] = [{"role": role} for role in sorted(roles)]
214
+ if enabled_repaired:
215
+ updates["enabled"] = 1
216
+ if updates:
217
+ client.update("User", email, updates)
218
+ return UserChange(created=False, added_roles=added, removed_roles=removed, enabled_repaired=enabled_repaired)
219
+
220
+
221
+ @dataclasses.dataclass(frozen=True)
222
+ class ApiKeyPair:
223
+ """A Frappe API key and secret. Kept out of reprs, like `SiteConfig`."""
224
+
225
+ api_key: str = dataclasses.field(repr=False)
226
+ api_secret: str = dataclasses.field(repr=False)
227
+
228
+
229
+ def generate_keys(client: _AccessClient, user: str) -> ApiKeyPair:
230
+ """Mint a fresh API secret for `user` (the key is kept); a secret can never be read back."""
231
+ result = client.call(_GENERATE_KEYS, {"user": user})
232
+ return ApiKeyPair(api_key=result["api_key"], api_secret=result["api_secret"])
233
+
234
+
235
+ class OrphanProbeDocumentError(RuntimeError):
236
+ """The probe's draft was created for real and could not be deleted afterwards.
237
+
238
+ Distinct from a probe that merely failed: a real document now exists
239
+ on the site and the probed credential (never granted delete) cannot
240
+ remove it. Somebody has to.
241
+ """
242
+
243
+ def __init__(self, doctype: str, name: str, cleanup_error: FrappeError) -> None:
244
+ super().__init__(
245
+ f"probe created a real {doctype} document ({name!r}) that could not be deleted: {cleanup_error}"
246
+ )
247
+ self.doctype = doctype
248
+ self.name = name
249
+ self.cleanup_error = cleanup_error
250
+
251
+
252
+ @dataclasses.dataclass(frozen=True)
253
+ class SubmitProbe:
254
+ """The outcome of attempting a submit-on-insert with a real credential.
255
+
256
+ `refused` is True only when create access was proven by a real draft
257
+ attempt (`can_create`), the server's per-flag answer says submit is
258
+ denied (`submit_flag`), and the submit-flagged insert raised
259
+ `PermissionError_`. Frappe's refusal text is the same whichever flag
260
+ failed, so a 403 alone proves nothing about submit. `error` set to
261
+ anything else means permission passed and validation stopped it;
262
+ `document_name` set means something posted -- an incident, not a retry.
263
+ """
264
+
265
+ refused: bool
266
+ can_create: bool | None = None
267
+ submit_flag: bool | None = None
268
+ error: FrappeError | None = None
269
+ document_name: str | None = None
270
+
271
+
272
+ def _has_permission(client: _AccessClient, doctype: str, perm_type: str) -> bool:
273
+ """The server's doctype-level answer to "does this credential hold `perm_type` on `doctype`"."""
274
+ result = client.call(_HAS_PERMISSION, {"doctype": doctype, "docname": "", "perm_type": perm_type})
275
+ return bool(result["has_permission"])
276
+
277
+
278
+ def prohibited_flags_allowed(config: SiteConfig, *, doctype: str, ptypes: frozenset[str]) -> dict[str, bool]:
279
+ """{ptype: allowed} for each of `ptypes` on `doctype`, per the server; nothing is written.
280
+
281
+ Frappe permissions are per doctype and per flag, so one call covers one
282
+ doctype. A doctype-level answer is trustworthy for submit/cancel/amend
283
+ (nothing document-scoped can widen a denied flag); it is not for
284
+ create, which `probe_submit` proves with a real draft instead.
285
+ """
286
+ with Client(config) as client:
287
+ return {ptype: _has_permission(client, doctype, ptype) for ptype in sorted(ptypes)}
288
+
289
+
290
+ def granted_flags_effective(config: SiteConfig, *, doctype: str, ptypes: frozenset[str]) -> dict[str, bool]:
291
+ """{ptype: effective} for each granted flag on `doctype`, per the server; nothing is written.
292
+
293
+ A Custom DocPerm row that exists and reads back is not proof it takes
294
+ effect -- a `read` row on `DocType` does not, measured live -- so a
295
+ positive grant is checked the same way a prohibited one is.
296
+
297
+ **Blind to permlevel.** `frappe.client.has_permission` answers a
298
+ doctype-level question and takes no permlevel of its own, so this cannot
299
+ tell a permlevel-1 DocPerm row's presence from a permlevel-0 grant
300
+ already on the same doctype -- a caller that asks this about a
301
+ permlevel-1 grant gets back "effective" whether or not that row exists.
302
+ Proving a grant above permlevel 0 needs a real round trip of a value
303
+ through the credential instead; do not call this for one.
304
+ """
305
+ with Client(config) as client:
306
+ return {ptype: _has_permission(client, doctype, ptype) for ptype in sorted(ptypes)}
307
+
308
+
309
+ def probe_submit(config: SiteConfig, *, company: str) -> SubmitProbe:
310
+ """Attempt a submit-on-insert with `config`'s credential and classify the server's answer.
311
+
312
+ The client-side guard is lifted on purpose (`may_submit=True`): only the
313
+ server's refusal counts. Frappe checks the submit permission in
314
+ `check_docstatus_transition` before any validation, and the probe
315
+ document lacks its mandatory `accounts` rows, so nothing can post even
316
+ if permission passes. Transport errors propagate: a probe that could
317
+ not run is not an answer.
318
+ """
319
+ probing_config = dataclasses.replace(config, may_submit=True)
320
+ with Client(probing_config) as client:
321
+ submit_flag = _has_permission(client, PROBE_DOCTYPE, "submit")
322
+
323
+ # Create access is proven by a real draft, not a doctype-level
324
+ # has_permission: only the draft sees a Company-scoped restriction,
325
+ # and a 403 from such a restriction would read as a submit refusal.
326
+ create_error = _probe_create(client, company)
327
+ if create_error is not None:
328
+ return SubmitProbe(refused=False, can_create=False, submit_flag=submit_flag, error=create_error)
329
+
330
+ try:
331
+ result = client.insert(
332
+ PROBE_DOCTYPE,
333
+ {
334
+ "company": company,
335
+ "posting_date": "2000-01-01",
336
+ "voucher_type": "Journal Entry",
337
+ "docstatus": 1,
338
+ },
339
+ )
340
+ except PermissionError_ as exc:
341
+ refused = submit_flag is False
342
+ return SubmitProbe(refused=refused, can_create=True, submit_flag=submit_flag, error=exc)
343
+ except FrappeError as exc:
344
+ return SubmitProbe(refused=False, can_create=True, submit_flag=submit_flag, error=exc)
345
+ return SubmitProbe(
346
+ refused=False,
347
+ can_create=True,
348
+ submit_flag=submit_flag,
349
+ document_name=result.get("name") if isinstance(result, dict) else None,
350
+ )
351
+
352
+
353
+ def _probe_create(client: Client, company: str) -> PermissionError_ | None:
354
+ """Prove create access against `company` with a real draft; None means proven.
355
+
356
+ Reaching validation (`ValidationError`) proves it -- for the missing
357
+ `accounts` rows, or for whatever else the company's setup objects to
358
+ first (on a perpetual-inventory company, ERPNext's
359
+ `StockAccountInvalidTransaction`, read as a validation failure from its
360
+ 417); a `PermissionError_` is returned for the caller to report; anything
361
+ else -- a rate limit, a 5xx -- propagates, since it answers nothing. A
362
+ draft that unexpectedly lands is deleted at once.
363
+ """
364
+ try:
365
+ draft = client.insert(
366
+ PROBE_DOCTYPE,
367
+ {"company": company, "posting_date": "2000-01-01", "voucher_type": "Journal Entry"},
368
+ )
369
+ except PermissionError_ as exc:
370
+ return exc
371
+ except ValidationError:
372
+ return None
373
+ name = draft.get("name") if isinstance(draft, dict) else None
374
+ if name:
375
+ try:
376
+ client.delete(PROBE_DOCTYPE, name)
377
+ except FrappeError as exc:
378
+ raise OrphanProbeDocumentError(PROBE_DOCTYPE, name, exc) from exc
379
+ return None