runningtab 0.1.0.dev3__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.
- runningtab/__init__.py +3 -0
- runningtab/access.py +419 -0
- runningtab/books.py +225 -0
- runningtab/chart.py +408 -0
- runningtab/cli.py +2216 -0
- runningtab/coa.py +212 -0
- runningtab/doc.py +185 -0
- runningtab/forecast/__init__.py +1 -0
- runningtab/forecast/compute.py +224 -0
- runningtab/forecast/doctype.py +228 -0
- runningtab/forecast/model.py +251 -0
- runningtab/forecast/names.py +8 -0
- runningtab/forecast/sources.py +85 -0
- runningtab/gl_codes.py +50 -0
- runningtab/health.py +661 -0
- runningtab/mercury/__init__.py +7 -0
- runningtab/mercury/categories.py +500 -0
- runningtab/mercury/classify.py +101 -0
- runningtab/mercury/client.py +360 -0
- runningtab/mercury/ingest.py +733 -0
- runningtab/mercury/mapping.py +130 -0
- runningtab/mercury/models.py +544 -0
- runningtab/mercury/posting.py +757 -0
- runningtab/mercury/receipts.py +304 -0
- runningtab/mercury/session.py +65 -0
- runningtab/mercury/state.py +285 -0
- runningtab/mercury/vouchers.py +951 -0
- runningtab/period/__init__.py +9 -0
- runningtab/period/balances.py +283 -0
- runningtab/period/bank.py +154 -0
- runningtab/period/close.py +510 -0
- runningtab/period/crosscheck.py +257 -0
- runningtab/period/csvfmt.py +86 -0
- runningtab/period/documents.py +260 -0
- runningtab/period/fence.py +277 -0
- runningtab/period/ledger.py +205 -0
- runningtab/period/lock.py +294 -0
- runningtab/period/manifest.py +259 -0
- runningtab/period/open_items.py +84 -0
- runningtab/period/package.py +140 -0
- runningtab/period/qbo.py +521 -0
- runningtab/period/qbo_map.py +174 -0
- runningtab/period/quarter.py +90 -0
- runningtab/period/readiness.py +146 -0
- runningtab/period/record.py +247 -0
- runningtab/period/status.py +312 -0
- runningtab/site.py +409 -0
- runningtab-0.1.0.dev3.dist-info/METADATA +726 -0
- runningtab-0.1.0.dev3.dist-info/RECORD +52 -0
- runningtab-0.1.0.dev3.dist-info/WHEEL +4 -0
- runningtab-0.1.0.dev3.dist-info/entry_points.txt +2 -0
- runningtab-0.1.0.dev3.dist-info/licenses/LICENSE +202 -0
runningtab/__init__.py
ADDED
runningtab/access.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Policy for the books bot: exactly what it may touch, and the one thing it may submit.
|
|
2
|
+
|
|
3
|
+
The bot's role holds create/write for drafting and the chart of accounts, and
|
|
4
|
+
submit on Bank Transaction alone; cancel and amend are written as "0"
|
|
5
|
+
everywhere, and so is submit on every other doctype. The server is asked to
|
|
6
|
+
confirm all of it before a key is stored.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from icedcoffee_sync.frappe.access import (
|
|
15
|
+
ApiKeyPair,
|
|
16
|
+
PermissionGrant,
|
|
17
|
+
RoleChange,
|
|
18
|
+
SubmitProbe,
|
|
19
|
+
UserChange,
|
|
20
|
+
ensure_role,
|
|
21
|
+
ensure_user,
|
|
22
|
+
generate_keys,
|
|
23
|
+
grant,
|
|
24
|
+
granted_flags_effective,
|
|
25
|
+
probe_submit,
|
|
26
|
+
prohibited_flags_allowed,
|
|
27
|
+
revoke_grants_except,
|
|
28
|
+
)
|
|
29
|
+
from icedcoffee_sync.frappe.client import SiteConfig
|
|
30
|
+
|
|
31
|
+
from runningtab.forecast.names import CASH_FORECAST
|
|
32
|
+
|
|
33
|
+
# Which role and user each identity is comes from the books directory's
|
|
34
|
+
# `books.toml` (`runningtab.books`); what each may touch is fixed here, and
|
|
35
|
+
# `bot_policy`/`reader_policy` put the two together.
|
|
36
|
+
|
|
37
|
+
# The one doctype the bot may submit. A Bank Transaction posts nothing to the
|
|
38
|
+
# general ledger on submit -- ERPNext writes the GL entries when a person
|
|
39
|
+
# reconciles it to a voucher -- and the Bank Reconciliation Tool only sees
|
|
40
|
+
# submitted rows, so the Mercury ingest submits the rows it creates and
|
|
41
|
+
# "machines draft, humans post" still holds in substance. Every other
|
|
42
|
+
# submittable doctype commits money on submit and stays draft-only.
|
|
43
|
+
BOT_SUBMITS: frozenset[str] = frozenset({"Bank Transaction"})
|
|
44
|
+
|
|
45
|
+
# Never granted: cancel or amend on any doctype, and submit outside
|
|
46
|
+
# BOT_SUBMITS. No DocType grant either: a role permission on DocType does not
|
|
47
|
+
# take effect (measured live), and nothing here reads meta.
|
|
48
|
+
GRANTS: tuple[PermissionGrant, ...] = (
|
|
49
|
+
PermissionGrant("Account", frozenset({"read", "write", "create", "delete"})),
|
|
50
|
+
PermissionGrant("Company", frozenset({"read", "write"})),
|
|
51
|
+
# `runningtab.period.fence` pushes Books Closed Through and its bypass role to
|
|
52
|
+
# permission level 1, so the ordinary permlevel-0 Company read above no
|
|
53
|
+
# longer reaches them. The Mercury ingest reads `accounts_frozen_till_date`
|
|
54
|
+
# every run to leave a closed quarter's rows to a person, so the bot is
|
|
55
|
+
# granted read at that level too -- read only, so the fence still proves
|
|
56
|
+
# the bot cannot write it.
|
|
57
|
+
PermissionGrant("Company", frozenset({"read"}), permlevel=1),
|
|
58
|
+
PermissionGrant("GL Entry", frozenset({"read"})),
|
|
59
|
+
PermissionGrant("Journal Entry", frozenset({"read", "write", "create"})),
|
|
60
|
+
PermissionGrant("Purchase Invoice", frozenset({"read", "write", "create"})),
|
|
61
|
+
PermissionGrant("Bank Transaction", frozenset({"read", "write", "create", "submit"})),
|
|
62
|
+
PermissionGrant("Supplier", frozenset({"read", "write", "create"})),
|
|
63
|
+
PermissionGrant("File", frozenset({"read", "write", "create"})),
|
|
64
|
+
# A forecast is written once and never changed: create without write or
|
|
65
|
+
# delete. Its child tables inherit the parent's permissions.
|
|
66
|
+
PermissionGrant(CASH_FORECAST, frozenset({"read", "create"})),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Exactly what `coa plan` reads and nothing else: the accounts, the company
|
|
70
|
+
# they hang off, and which accounts carry ledger entries.
|
|
71
|
+
#
|
|
72
|
+
# GL Entry read is a deliberate decision, not an oversight, so that reviews
|
|
73
|
+
# stop re-raising it: plan needs only to know WHETHER an account has entries,
|
|
74
|
+
# and asking that cheaply (`list("GL Entry", filters=[["account","=",...]],
|
|
75
|
+
# limit=1)`) means read on the ledger. Narrowing it would take a whitelisted
|
|
76
|
+
# method on the site and a custom Frappe app to hold it. Reading the whole
|
|
77
|
+
# ledger is accepted on purpose: the reader is the tooling that keeps these
|
|
78
|
+
# books, reading the books it keeps.
|
|
79
|
+
READER_GRANTS: tuple[PermissionGrant, ...] = (
|
|
80
|
+
PermissionGrant("Account", frozenset({"read"})),
|
|
81
|
+
PermissionGrant("Company", frozenset({"read"})),
|
|
82
|
+
# `runningtab.period.fence` pushes Books Closed Through and its bypass role to
|
|
83
|
+
# permission level 1, so the ordinary permlevel-0 Company read above no
|
|
84
|
+
# longer reaches them. `status` and `health` still need to read
|
|
85
|
+
# `accounts_frozen_till_date`, so the reader is granted read at that level
|
|
86
|
+
# too -- as the bot is in `GRANTS`, for the Mercury ingest. Removing
|
|
87
|
+
# either grant makes that credential's lock read refuse by name
|
|
88
|
+
# (`runningtab.period.lock.read_visible_lock`).
|
|
89
|
+
PermissionGrant("Company", frozenset({"read"}), permlevel=1),
|
|
90
|
+
PermissionGrant("GL Entry", frozenset({"read"})),
|
|
91
|
+
# Read by `runningtab period export`: the ledger's parties, the fiscal years,
|
|
92
|
+
# and the vouchers whose attachments a private-file download is checked
|
|
93
|
+
# against.
|
|
94
|
+
PermissionGrant("Fiscal Year", frozenset({"read"})),
|
|
95
|
+
PermissionGrant("Customer", frozenset({"read"})),
|
|
96
|
+
PermissionGrant("Journal Entry", frozenset({"read"})),
|
|
97
|
+
PermissionGrant("Purchase Invoice", frozenset({"read"})),
|
|
98
|
+
PermissionGrant("Payment Entry", frozenset({"read"})),
|
|
99
|
+
PermissionGrant("Sales Invoice", frozenset({"read"})),
|
|
100
|
+
PermissionGrant("Bank Transaction", frozenset({"read"})),
|
|
101
|
+
PermissionGrant("File", frozenset({"read"})),
|
|
102
|
+
PermissionGrant("Supplier", frozenset({"read"})),
|
|
103
|
+
# Version ships read to System Manager and Administrator only
|
|
104
|
+
# (frappe/core/doctype/version/version.json); the reader inherits
|
|
105
|
+
# neither, so this is explicit. `runningtab.period.lock.lock_history` reads
|
|
106
|
+
# it to name who moved Books Closed Through and when -- status names who
|
|
107
|
+
# reopened a quarter.
|
|
108
|
+
PermissionGrant("Version", frozenset({"read"})),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Every mutating flag, on every doctype the writing bot can reach. Read access
|
|
112
|
+
# is worthless as a boundary unless the absence of write access is proven
|
|
113
|
+
# rather than assumed: a role inherited from somewhere else, or a stray grant
|
|
114
|
+
# left on the user, would otherwise pass unnoticed.
|
|
115
|
+
#
|
|
116
|
+
# The list is deliberately wider than the reader's own grants. Checking only
|
|
117
|
+
# the doctypes we granted would prove nothing about an inherited permission on
|
|
118
|
+
# one we did not, and an inherited permission is the whole thing this check
|
|
119
|
+
# exists to catch. Every flag that changes a document is listed -- cancel and
|
|
120
|
+
# amend included, which a "can it write" check reads past too easily.
|
|
121
|
+
MUTATING_PTYPES: frozenset[str] = frozenset({"write", "create", "delete", "submit", "cancel", "amend"})
|
|
122
|
+
READER_PROHIBITED: dict[str, frozenset[str]] = dict.fromkeys(
|
|
123
|
+
(
|
|
124
|
+
"Account",
|
|
125
|
+
"Company",
|
|
126
|
+
"GL Entry",
|
|
127
|
+
"Journal Entry",
|
|
128
|
+
"Purchase Invoice",
|
|
129
|
+
"Bank Transaction",
|
|
130
|
+
"Supplier",
|
|
131
|
+
"File",
|
|
132
|
+
"Fiscal Year",
|
|
133
|
+
"Customer",
|
|
134
|
+
"Payment Entry",
|
|
135
|
+
"Sales Invoice",
|
|
136
|
+
"Version",
|
|
137
|
+
),
|
|
138
|
+
MUTATING_PTYPES,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# What the reader can do that we did not grant and cannot take away here.
|
|
142
|
+
#
|
|
143
|
+
# MEASURED on the live site 2026-08-27, not inferred: the reader's role grants
|
|
144
|
+
# File nothing, the server reports create/write/delete on File as allowed, and
|
|
145
|
+
# a real insert SUCCEEDED (then was deleted again). Frappe hands this to
|
|
146
|
+
# authenticated users so attachments work; taking it away means editing a
|
|
147
|
+
# role's permissions for the whole site, and `DocPerm` is not even readable
|
|
148
|
+
# over REST to find out which role supplies it.
|
|
149
|
+
#
|
|
150
|
+
# Recorded here rather than dropped from the check, so that verification keeps
|
|
151
|
+
# reporting it every run instead of quietly narrowing what "read-only" claims.
|
|
152
|
+
# It reaches no account, no ledger entry and no company record -- but it is
|
|
153
|
+
# write access, and calling the reader "unable to write anything" would be a
|
|
154
|
+
# lie.
|
|
155
|
+
READER_INHERITED_WRITES: dict[str, frozenset[str]] = {"File": frozenset({"create", "write", "delete"})}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclasses.dataclass(frozen=True)
|
|
159
|
+
class Policy:
|
|
160
|
+
"""One identity on the books site: its role, its user, and what that role may touch."""
|
|
161
|
+
|
|
162
|
+
role: str
|
|
163
|
+
user: str
|
|
164
|
+
first_name: str
|
|
165
|
+
grants: tuple[PermissionGrant, ...]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def bot_policy(role: str, user: str, first_name: str) -> Policy:
|
|
169
|
+
"""The writing bot: `GRANTS`, held by the role and user the books name."""
|
|
170
|
+
return Policy(role, user, first_name, GRANTS)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def reader_policy(role: str, user: str, first_name: str) -> Policy:
|
|
174
|
+
"""The reader: `READER_GRANTS`, held by the role and user the books name."""
|
|
175
|
+
return Policy(role, user, first_name, READER_GRANTS)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# Frappe permissions are per doctype and per flag: the insert-based probe
|
|
179
|
+
# proves submit on Journal Entry only, so every prohibited flag is checked on
|
|
180
|
+
# every submittable doctype the bot is granted.
|
|
181
|
+
SUBMITTABLE_DOCTYPES: frozenset[str] = frozenset({"Journal Entry", "Purchase Invoice", "Bank Transaction"})
|
|
182
|
+
PROHIBITED_PTYPES: frozenset[str] = frozenset({"submit", "cancel", "amend"})
|
|
183
|
+
|
|
184
|
+
# Cash Forecast is not submittable; what must be refused is changing or
|
|
185
|
+
# removing a forecast once the bot has created it.
|
|
186
|
+
FORECAST_PROHIBITED: frozenset[str] = frozenset({"write", "delete"})
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def prohibited_ptypes_for(doctype: str) -> frozenset[str]:
|
|
190
|
+
"""The committing flags the bot must be denied on `doctype`: all three, less submit where it is granted."""
|
|
191
|
+
if doctype in BOT_SUBMITS:
|
|
192
|
+
return PROHIBITED_PTYPES - {"submit"}
|
|
193
|
+
return PROHIBITED_PTYPES
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class KeyRotationFailedError(RuntimeError):
|
|
197
|
+
"""`generate_keys` failed after it may already have rotated the live secret.
|
|
198
|
+
|
|
199
|
+
The call has no dry-run and no read-back, so a timeout looks the same as
|
|
200
|
+
a request that never arrived; assume the rotation landed.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclasses.dataclass(frozen=True)
|
|
205
|
+
class ProvisionReport:
|
|
206
|
+
"""What `provision` changed, and the bot's freshly minted credential."""
|
|
207
|
+
|
|
208
|
+
role_created: bool
|
|
209
|
+
role_desk_access_repaired: bool
|
|
210
|
+
role_disabled_repaired: bool
|
|
211
|
+
grants: dict[str, list[tuple[str, str]]]
|
|
212
|
+
revoked: list[str]
|
|
213
|
+
user: UserChange
|
|
214
|
+
keys: ApiKeyPair
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class _ProvisionClient(Protocol):
|
|
218
|
+
"""The slice of `Client` provisioning needs."""
|
|
219
|
+
|
|
220
|
+
def get(self, doctype: str, name: str) -> Any: ...
|
|
221
|
+
def insert(self, doctype: str, doc: dict[str, Any]) -> Any: ...
|
|
222
|
+
def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
|
|
223
|
+
def call(self, dotted_path: str, params: dict[str, Any] | None = None) -> Any: ...
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _grant_label(doctype: str, permlevel: int) -> str:
|
|
227
|
+
"""`"<doctype>"` at permlevel 0, `"<doctype> (permlevel N)"` otherwise.
|
|
228
|
+
|
|
229
|
+
The spelling `revoke_grants_except` already uses for a revoked row. Every
|
|
230
|
+
dict this module keys per grant uses it too: two grants on the same
|
|
231
|
+
doctype at different permlevels -- the bot's and the reader's Company
|
|
232
|
+
grants both are, now -- must never collapse onto one key and silently
|
|
233
|
+
drop one of the two findings.
|
|
234
|
+
"""
|
|
235
|
+
return doctype if permlevel == 0 else f"{doctype} (permlevel {permlevel})"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def provision(client: _ProvisionClient, policy: Policy) -> ProvisionReport:
|
|
239
|
+
"""Create or repair `policy`'s role and user to exactly what it describes, and mint a fresh key.
|
|
240
|
+
|
|
241
|
+
Idempotent up to the key: grants are rewritten in full, stray grants and
|
|
242
|
+
roles revoked, a disabled role or user re-enabled. A role without desk
|
|
243
|
+
access makes the user a Website User, which the REST API serves fine.
|
|
244
|
+
"""
|
|
245
|
+
role: RoleChange = ensure_role(client, policy.role)
|
|
246
|
+
sent = {_grant_label(g.doctype, g.permlevel): grant(client, policy.role, g) for g in policy.grants}
|
|
247
|
+
revoked = revoke_grants_except(client, policy.role, keep=frozenset((g.doctype, g.permlevel) for g in policy.grants))
|
|
248
|
+
user = ensure_user(client, policy.user, first_name=policy.first_name, roles=frozenset({policy.role}))
|
|
249
|
+
try:
|
|
250
|
+
keys = generate_keys(client, policy.user)
|
|
251
|
+
except Exception as exc:
|
|
252
|
+
raise KeyRotationFailedError(str(exc)) from exc
|
|
253
|
+
return ProvisionReport(
|
|
254
|
+
role_created=role.created,
|
|
255
|
+
role_desk_access_repaired=role.desk_access_repaired,
|
|
256
|
+
role_disabled_repaired=role.disabled_repaired,
|
|
257
|
+
grants=sent,
|
|
258
|
+
revoked=revoked,
|
|
259
|
+
user=user,
|
|
260
|
+
keys=keys,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def prohibited_flags_report(config: SiteConfig) -> dict[str, list[str]]:
|
|
265
|
+
"""{doctype: [flag, ...]} for every prohibited flag the server still allows this credential.
|
|
266
|
+
|
|
267
|
+
Submit on Bank Transaction is granted, not prohibited, and `missing_grants_report`
|
|
268
|
+
is what proves the server honours it. Cash Forecast is checked for write and
|
|
269
|
+
delete, which the bot must never hold.
|
|
270
|
+
"""
|
|
271
|
+
leaked: dict[str, list[str]] = {}
|
|
272
|
+
for doctype in sorted(SUBMITTABLE_DOCTYPES):
|
|
273
|
+
flags = prohibited_flags_allowed(config, doctype=doctype, ptypes=prohibited_ptypes_for(doctype))
|
|
274
|
+
allowed = sorted(ptype for ptype, value in flags.items() if value)
|
|
275
|
+
if allowed:
|
|
276
|
+
leaked[doctype] = allowed
|
|
277
|
+
flags = prohibited_flags_allowed(config, doctype=CASH_FORECAST, ptypes=FORECAST_PROHIBITED)
|
|
278
|
+
if allowed := sorted(ptype for ptype, value in flags.items() if value):
|
|
279
|
+
leaked[CASH_FORECAST] = allowed
|
|
280
|
+
return leaked
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def missing_grants_report(config: SiteConfig, grants: tuple[PermissionGrant, ...] = GRANTS) -> dict[str, list[str]]:
|
|
284
|
+
"""{label: [flag, ...]} for every permlevel-0 granted flag the server does not honour.
|
|
285
|
+
|
|
286
|
+
A grant above permlevel 0 is left out entirely here -- never asked about,
|
|
287
|
+
never reported missing. `granted_flags_effective` asks
|
|
288
|
+
`frappe.client.has_permission`, which has no permlevel of its own: it
|
|
289
|
+
cannot tell a permlevel-1 DocPerm row's presence from a permlevel-0 grant
|
|
290
|
+
already on the same doctype, so treating its answer as proof for a
|
|
291
|
+
permlevel-1 grant would be a check that always reads "effective" whether
|
|
292
|
+
or not the row exists -- a false green. `skipped_grants` names these
|
|
293
|
+
instead, and what actually proves them is a live round trip of the
|
|
294
|
+
field's value through the credential (`tests/test_live_fence.py`), not a
|
|
295
|
+
doctype-level yes/no.
|
|
296
|
+
"""
|
|
297
|
+
missing: dict[str, list[str]] = {}
|
|
298
|
+
for g in grants:
|
|
299
|
+
if g.permlevel > 0:
|
|
300
|
+
continue
|
|
301
|
+
flags = granted_flags_effective(config, doctype=g.doctype, ptypes=g.ptypes)
|
|
302
|
+
not_effective = sorted(ptype for ptype, value in flags.items() if not value)
|
|
303
|
+
if not_effective:
|
|
304
|
+
missing[_grant_label(g.doctype, g.permlevel)] = not_effective
|
|
305
|
+
return missing
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def skipped_grants(grants: tuple[PermissionGrant, ...] = GRANTS) -> list[str]:
|
|
309
|
+
"""Labels of the grants `missing_grants_report` cannot check -- see its docstring.
|
|
310
|
+
|
|
311
|
+
Static over `grants` alone: which grants sit above permlevel 0 is known
|
|
312
|
+
before anything is asked of the server, so this makes no network call.
|
|
313
|
+
"""
|
|
314
|
+
return [_grant_label(g.doctype, g.permlevel) for g in grants if g.permlevel > 0]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def reader_prohibited_report(config: SiteConfig) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
|
|
318
|
+
"""Which mutating flags the server still allows a reader: (unexpected, known-inherited).
|
|
319
|
+
|
|
320
|
+
Split rather than merged so the known ones stay visible on every run
|
|
321
|
+
without failing it, and anything new fails immediately instead of hiding
|
|
322
|
+
among them.
|
|
323
|
+
"""
|
|
324
|
+
leaked: dict[str, list[str]] = {}
|
|
325
|
+
inherited: dict[str, list[str]] = {}
|
|
326
|
+
for doctype, ptypes in sorted(READER_PROHIBITED.items()):
|
|
327
|
+
flags = prohibited_flags_allowed(config, doctype=doctype, ptypes=ptypes)
|
|
328
|
+
allowed = {ptype for ptype, value in flags.items() if value}
|
|
329
|
+
known = READER_INHERITED_WRITES.get(doctype, frozenset())
|
|
330
|
+
if expected := sorted(allowed & known):
|
|
331
|
+
inherited[doctype] = expected
|
|
332
|
+
if unexpected := sorted(allowed - known):
|
|
333
|
+
leaked[doctype] = unexpected
|
|
334
|
+
return leaked, inherited
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def check_reader_credential(config: SiteConfig) -> CredentialCheck:
|
|
338
|
+
"""Prove a reader credential can read what plan needs and cannot write anything.
|
|
339
|
+
|
|
340
|
+
No submit probe: the probe works by inserting a Journal Entry, which a
|
|
341
|
+
reader may not create at all, so its refusal would prove nothing about
|
|
342
|
+
submit. Asking the server which flags it honours is the whole check here,
|
|
343
|
+
and `probe` is recorded as refused because a credential that cannot create
|
|
344
|
+
cannot submit either.
|
|
345
|
+
|
|
346
|
+
Raises `CredentialCheckError` (naming the stage) when a check cannot run.
|
|
347
|
+
"""
|
|
348
|
+
try:
|
|
349
|
+
leaked, inherited = reader_prohibited_report(config)
|
|
350
|
+
except Exception as exc:
|
|
351
|
+
raise CredentialCheckError("prohibited", exc) from exc
|
|
352
|
+
probe = SubmitProbe(refused=True, can_create=False, submit_flag=False)
|
|
353
|
+
if leaked:
|
|
354
|
+
return CredentialCheck(probe=probe, leaked=leaked, inherited=inherited)
|
|
355
|
+
try:
|
|
356
|
+
missing = missing_grants_report(config, READER_GRANTS)
|
|
357
|
+
except Exception as exc:
|
|
358
|
+
raise CredentialCheckError("grants", exc) from exc
|
|
359
|
+
return CredentialCheck(probe=probe, leaked=leaked, missing=missing, inherited=inherited)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class CredentialCheckError(RuntimeError):
|
|
363
|
+
"""A credential check could not run; `stage` says which one."""
|
|
364
|
+
|
|
365
|
+
def __init__(self, stage: str, cause: Exception) -> None:
|
|
366
|
+
super().__init__(f"{stage}: {cause}")
|
|
367
|
+
self.stage = stage
|
|
368
|
+
self.cause = cause
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@dataclasses.dataclass(frozen=True)
|
|
372
|
+
class CredentialCheck:
|
|
373
|
+
"""The server's verdict on a credential, stage by stage; later stages are None once one fails."""
|
|
374
|
+
|
|
375
|
+
probe: SubmitProbe
|
|
376
|
+
leaked: dict[str, list[str]] | None = None
|
|
377
|
+
missing: dict[str, list[str]] | None = None
|
|
378
|
+
# Allowed, expected, and not a failure -- see READER_INHERITED_WRITES.
|
|
379
|
+
inherited: dict[str, list[str]] | None = None
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def passed(self) -> bool:
|
|
383
|
+
"""True only when the probe's submit was refused, no prohibited flag leaked, and every grant is effective.
|
|
384
|
+
|
|
385
|
+
For the bot that means submit denied on Journal Entry and Purchase
|
|
386
|
+
Invoice, cancel and amend denied everywhere, and submit on Bank
|
|
387
|
+
Transaction actually honoured -- a grant on paper the server ignores
|
|
388
|
+
would fail the ingest on its first row.
|
|
389
|
+
"""
|
|
390
|
+
return self.probe.refused and self.leaked == {} and self.missing == {}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def check_credential(config: SiteConfig, company: str) -> CredentialCheck:
|
|
394
|
+
"""Prove a credential is scoped as policy says, stopping at the first stage that fails.
|
|
395
|
+
|
|
396
|
+
Raises `CredentialCheckError` (naming the stage) when a check cannot run;
|
|
397
|
+
an `OrphanProbeDocumentError` from the probe propagates as itself.
|
|
398
|
+
"""
|
|
399
|
+
from icedcoffee_sync.frappe.access import OrphanProbeDocumentError
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
probe = probe_submit(config, company=company)
|
|
403
|
+
except OrphanProbeDocumentError:
|
|
404
|
+
raise
|
|
405
|
+
except Exception as exc:
|
|
406
|
+
raise CredentialCheckError("probe", exc) from exc
|
|
407
|
+
if not probe.refused:
|
|
408
|
+
return CredentialCheck(probe=probe)
|
|
409
|
+
try:
|
|
410
|
+
leaked = prohibited_flags_report(config)
|
|
411
|
+
except Exception as exc:
|
|
412
|
+
raise CredentialCheckError("prohibited", exc) from exc
|
|
413
|
+
if leaked:
|
|
414
|
+
return CredentialCheck(probe=probe, leaked=leaked)
|
|
415
|
+
try:
|
|
416
|
+
missing = missing_grants_report(config)
|
|
417
|
+
except Exception as exc:
|
|
418
|
+
raise CredentialCheckError("grants", exc) from exc
|
|
419
|
+
return CredentialCheck(probe=probe, leaked=leaked, missing=missing)
|
runningtab/books.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""The books directory: whose books runningtab is keeping, and everything it reads about them.
|
|
2
|
+
|
|
3
|
+
runningtab is the tool; the books are data it is pointed at. A books
|
|
4
|
+
directory holds the chart (`chart.coa`), the Mercury account map
|
|
5
|
+
(`mercury.toml`), the forecast (`forecast.toml`), the QuickBooks import map
|
|
6
|
+
(`qbo.toml`), and `books.toml`, which says where the ledger is and which
|
|
7
|
+
identities and secrets reach it.
|
|
8
|
+
|
|
9
|
+
There is no default directory and no fallback to one inside the package:
|
|
10
|
+
`--books DIR` or `RUNNINGTAB_BOOKS` names it, or the command refuses. A
|
|
11
|
+
silent fallback would plan against books nobody meant, and every difference
|
|
12
|
+
it reported would look real.
|
|
13
|
+
|
|
14
|
+
`books.toml` is read strictly. An unknown key is refused by its dotted name,
|
|
15
|
+
and so is a missing one, because a typo that quietly became a default is the
|
|
16
|
+
kind of mistake nobody notices until the ledger does.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import dataclasses
|
|
22
|
+
import os
|
|
23
|
+
import pathlib
|
|
24
|
+
import re
|
|
25
|
+
import tomllib
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from runningtab.access import Policy, bot_policy, reader_policy
|
|
30
|
+
from runningtab.site import Site, Target, normalised_host
|
|
31
|
+
|
|
32
|
+
BOOKS_ENV = "RUNNINGTAB_BOOKS"
|
|
33
|
+
BOOKS_FILE = "books.toml"
|
|
34
|
+
CHART_FILE = "chart.coa"
|
|
35
|
+
MERCURY_FILE = "mercury.toml"
|
|
36
|
+
FORECAST_FILE = "forecast.toml"
|
|
37
|
+
QBO_FILE = "qbo.toml"
|
|
38
|
+
_DATA_FILES = (CHART_FILE, MERCURY_FILE, FORECAST_FILE, QBO_FILE)
|
|
39
|
+
|
|
40
|
+
# One path segment: the name namespaces the Mercury ingest's local state.
|
|
41
|
+
_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
|
42
|
+
|
|
43
|
+
# Every key `books.toml` may hold, and its type. All of them are required.
|
|
44
|
+
_STR = "a string"
|
|
45
|
+
_STR_LIST = "a list of strings"
|
|
46
|
+
_SCHEMA: dict[str, Any] = {
|
|
47
|
+
"name": _STR,
|
|
48
|
+
"chart_doc": _STR,
|
|
49
|
+
"site": {"host": _STR, "test_hosts": _STR_LIST},
|
|
50
|
+
"secrets": {
|
|
51
|
+
"project": _STR,
|
|
52
|
+
"books": _STR,
|
|
53
|
+
"books_readonly": _STR,
|
|
54
|
+
"mercury": _STR,
|
|
55
|
+
"test_admin": _STR,
|
|
56
|
+
},
|
|
57
|
+
"record_buckets": {"books": _STR, "test": _STR},
|
|
58
|
+
"bot": {"role": _STR, "user": _STR, "first_name": _STR},
|
|
59
|
+
"reader": {"role": _STR, "user": _STR, "first_name": _STR},
|
|
60
|
+
"fence": {"probe_role": _STR},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class BooksError(ValueError):
|
|
65
|
+
"""No books directory was named, or the one named is not a set of books runningtab can read."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclasses.dataclass(frozen=True)
|
|
69
|
+
class Books:
|
|
70
|
+
"""One loaded books directory."""
|
|
71
|
+
|
|
72
|
+
directory: pathlib.Path
|
|
73
|
+
# Names the books in what runningtab writes for a person (the period
|
|
74
|
+
# package's manifest) and namespaces its local state.
|
|
75
|
+
name: str
|
|
76
|
+
# `docs/chart-of-accounts.md`-style page whose account tables
|
|
77
|
+
# `runningtab chart render-doc` regenerates from the chart.
|
|
78
|
+
chart_doc: pathlib.Path
|
|
79
|
+
site: Site
|
|
80
|
+
# The record buckets a closed quarter's package is written to, one per
|
|
81
|
+
# target, so a caller never spells a bucket name itself.
|
|
82
|
+
record_buckets: Mapping[Target, str]
|
|
83
|
+
bot: Policy
|
|
84
|
+
reader: Policy
|
|
85
|
+
# The role `runningtab.period.fence` writes as Company's bypass role while
|
|
86
|
+
# proving the fence holds. Held by nobody; see that module.
|
|
87
|
+
fence_probe_role: str
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def chart_path(self) -> pathlib.Path:
|
|
91
|
+
"""The chart of accounts."""
|
|
92
|
+
return self.directory / CHART_FILE
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def mercury_path(self) -> pathlib.Path:
|
|
96
|
+
"""Which chart account and Bank Account each Mercury account is."""
|
|
97
|
+
return self.directory / MERCURY_FILE
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def forecast_path(self) -> pathlib.Path:
|
|
101
|
+
"""The expected cash movements `runningtab forecast` reads."""
|
|
102
|
+
return self.directory / FORECAST_FILE
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def qbo_path(self) -> pathlib.Path:
|
|
106
|
+
"""What QuickBooks Online needs to know about the chart."""
|
|
107
|
+
return self.directory / QBO_FILE
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def resolve_books(flag: pathlib.Path | None, environ: Mapping[str, str] = os.environ) -> Books:
|
|
111
|
+
"""Load the books directory `--books` names, else the one `RUNNINGTAB_BOOKS` names, else refuse.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
BooksError: Neither names a directory, or the one named does not load.
|
|
115
|
+
"""
|
|
116
|
+
if flag is not None:
|
|
117
|
+
return load_books(flag)
|
|
118
|
+
named = environ.get(BOOKS_ENV, "")
|
|
119
|
+
if not named:
|
|
120
|
+
raise BooksError(
|
|
121
|
+
f"no books directory named: pass --books DIR or set {BOOKS_ENV}. "
|
|
122
|
+
"runningtab has no default books, so that it never plans against books nobody meant."
|
|
123
|
+
)
|
|
124
|
+
return load_books(pathlib.Path(named))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_books(directory: pathlib.Path) -> Books:
|
|
128
|
+
"""Read `directory/books.toml` and check the directory holds every file runningtab reads.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
BooksError: Naming the file, key or host that is wrong.
|
|
132
|
+
"""
|
|
133
|
+
directory = directory.resolve()
|
|
134
|
+
books_file = directory / BOOKS_FILE
|
|
135
|
+
try:
|
|
136
|
+
text = books_file.read_text(encoding="utf-8")
|
|
137
|
+
except OSError as exc:
|
|
138
|
+
raise BooksError(f"{books_file}: cannot read the books directory's {BOOKS_FILE}: {exc}") from exc
|
|
139
|
+
missing = [name for name in _DATA_FILES if not (directory / name).is_file()]
|
|
140
|
+
if missing:
|
|
141
|
+
raise BooksError(f"books directory {directory} is missing: {', '.join(missing)}")
|
|
142
|
+
try:
|
|
143
|
+
data = tomllib.loads(text)
|
|
144
|
+
except tomllib.TOMLDecodeError as exc:
|
|
145
|
+
raise BooksError(f"{books_file} is not valid TOML: {exc}") from exc
|
|
146
|
+
problems = _schema_problems(data, _SCHEMA, prefix="")
|
|
147
|
+
if problems:
|
|
148
|
+
raise BooksError(f"{books_file}:\n " + "\n ".join(problems))
|
|
149
|
+
return _books(directory, books_file, data)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _schema_problems(data: Mapping[str, Any], schema: Mapping[str, Any], *, prefix: str) -> list[str]:
|
|
153
|
+
"""Every unknown, missing or wrongly typed key under `prefix`, by its dotted name."""
|
|
154
|
+
problems: list[str] = []
|
|
155
|
+
for key in sorted(data.keys() - schema.keys()):
|
|
156
|
+
problems.append(f"{prefix}{key}: unknown key")
|
|
157
|
+
for key, expected in schema.items():
|
|
158
|
+
where = f"{prefix}{key}"
|
|
159
|
+
if key not in data:
|
|
160
|
+
problems.append(f"{where}: missing")
|
|
161
|
+
continue
|
|
162
|
+
value = data[key]
|
|
163
|
+
if isinstance(expected, dict):
|
|
164
|
+
if isinstance(value, dict):
|
|
165
|
+
problems.extend(_schema_problems(value, expected, prefix=f"{where}."))
|
|
166
|
+
else:
|
|
167
|
+
problems.append(f"{where}: must be a table")
|
|
168
|
+
elif expected == _STR and not (isinstance(value, str) and value.strip()):
|
|
169
|
+
problems.append(f"{where}: must be {_STR}, not empty")
|
|
170
|
+
elif expected == _STR_LIST and not (
|
|
171
|
+
isinstance(value, list) and all(isinstance(item, str) and item.strip() for item in value)
|
|
172
|
+
):
|
|
173
|
+
problems.append(f"{where}: must be {_STR_LIST}")
|
|
174
|
+
return problems
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _books(directory: pathlib.Path, books_file: pathlib.Path, data: Mapping[str, Any]) -> Books:
|
|
178
|
+
"""Build `Books` from data `_schema_problems` already passed, refusing what only makes sense together."""
|
|
179
|
+
name = data["name"]
|
|
180
|
+
if not _NAME.match(name):
|
|
181
|
+
raise BooksError(f"{books_file}: name {name!r} must be letters, digits, '-' and '_' only; it names a directory")
|
|
182
|
+
site_data, secrets = data["site"], data["secrets"]
|
|
183
|
+
site = Site(
|
|
184
|
+
host=site_data["host"],
|
|
185
|
+
test_hosts=frozenset(site_data["test_hosts"]),
|
|
186
|
+
secret_project=secrets["project"],
|
|
187
|
+
books_secret=secrets["books"],
|
|
188
|
+
books_readonly_secret=secrets["books_readonly"],
|
|
189
|
+
mercury_secret=secrets["mercury"],
|
|
190
|
+
test_admin_secret=secrets["test_admin"],
|
|
191
|
+
)
|
|
192
|
+
# The allowlist exists so that an alias of the ledger is refused. A list
|
|
193
|
+
# that names the ledger itself would let `--target test` reach it.
|
|
194
|
+
ledger = normalised_host(site.host)
|
|
195
|
+
if any(normalised_host(host) == ledger for host in site.test_hosts):
|
|
196
|
+
raise BooksError(
|
|
197
|
+
f"{books_file}: site.test_hosts names the books host {site.host}; "
|
|
198
|
+
"the test target must never be able to reach the ledger"
|
|
199
|
+
)
|
|
200
|
+
bot, reader = data["bot"], data["reader"]
|
|
201
|
+
if bot["user"] == reader["user"] or bot["role"] == reader["role"]:
|
|
202
|
+
raise BooksError(
|
|
203
|
+
f"{books_file}: bot and reader must be different users with different roles; "
|
|
204
|
+
"the reader's whole point is a credential that cannot write"
|
|
205
|
+
)
|
|
206
|
+
# `runningtab.period.fence` writes this role as the one allowed past a
|
|
207
|
+
# closed quarter, so it must be held by nobody. Frappe gives every user
|
|
208
|
+
# "Guest" and "All", and the bot and reader hold their own roles.
|
|
209
|
+
probe_role = data["fence"]["probe_role"]
|
|
210
|
+
if probe_role in {"Guest", "All", bot["role"], reader["role"]}:
|
|
211
|
+
raise BooksError(
|
|
212
|
+
f"{books_file}: fence.probe_role {probe_role!r} is a role somebody holds; "
|
|
213
|
+
"it must be one nobody is ever granted"
|
|
214
|
+
)
|
|
215
|
+
buckets = data["record_buckets"]
|
|
216
|
+
return Books(
|
|
217
|
+
directory=directory,
|
|
218
|
+
name=name,
|
|
219
|
+
chart_doc=(directory / data["chart_doc"]).resolve(),
|
|
220
|
+
site=site,
|
|
221
|
+
record_buckets={Target.BOOKS: buckets["books"], Target.TEST: buckets["test"]},
|
|
222
|
+
bot=bot_policy(bot["role"], bot["user"], bot["first_name"]),
|
|
223
|
+
reader=reader_policy(reader["role"], reader["user"], reader["first_name"]),
|
|
224
|
+
fence_probe_role=probe_role,
|
|
225
|
+
)
|