From 920f38e4c72615e13a06c4bd26b155f850ac40df Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 10 Jul 2026 12:51:16 +0200 Subject: [PATCH] chore: sync GovOPlaN module split state --- .gitignore | 23 + README.md | 10 +- docs/AUDIT_TRACE_CONTEXT.md | 82 ++++ src/govoplan_audit.egg-info/PKG-INFO | 18 - src/govoplan_audit.egg-info/SOURCES.txt | 18 - .../dependency_links.txt | 1 - src/govoplan_audit.egg-info/entry_points.txt | 2 - src/govoplan_audit.egg-info/requires.txt | 2 - src/govoplan_audit.egg-info/top_level.txt | 1 - .../__pycache__/__init__.cpython-312.pyc | Bin 193 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 193 -> 0 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 229 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 229 -> 0 bytes .../__pycache__/manifest.cpython-312.pyc | Bin 1527 -> 0 bytes .../__pycache__/manifest.cpython-313.pyc | Bin 1478 -> 0 bytes .../api/__pycache__/__init__.cpython-312.pyc | Bin 168 -> 0 bytes .../api/__pycache__/__init__.cpython-313.pyc | Bin 168 -> 0 bytes .../v1/__pycache__/__init__.cpython-312.pyc | Bin 171 -> 0 bytes .../v1/__pycache__/__init__.cpython-313.pyc | Bin 171 -> 0 bytes .../api/v1/__pycache__/routes.cpython-312.pyc | Bin 13445 -> 0 bytes .../api/v1/__pycache__/routes.cpython-313.pyc | Bin 13601 -> 0 bytes .../v1/__pycache__/schemas.cpython-312.pyc | Bin 2185 -> 0 bytes .../v1/__pycache__/schemas.cpython-313.pyc | Bin 2315 -> 0 bytes src/govoplan_audit/backend/api/v1/routes.py | 437 ++++++++++++++++-- src/govoplan_audit/backend/api/v1/schemas.py | 11 + .../db/__pycache__/__init__.cpython-312.pyc | Bin 225 -> 0 bytes .../db/__pycache__/__init__.cpython-313.pyc | Bin 225 -> 0 bytes .../db/__pycache__/models.cpython-312.pyc | Bin 2161 -> 0 bytes .../db/__pycache__/models.cpython-313.pyc | Bin 2151 -> 0 bytes src/govoplan_audit/backend/db/models.py | 6 +- 30 files changed, 533 insertions(+), 78 deletions(-) create mode 100644 .gitignore create mode 100644 docs/AUDIT_TRACE_CONTEXT.md delete mode 100644 src/govoplan_audit.egg-info/PKG-INFO delete mode 100644 src/govoplan_audit.egg-info/SOURCES.txt delete mode 100644 src/govoplan_audit.egg-info/dependency_links.txt delete mode 100644 src/govoplan_audit.egg-info/entry_points.txt delete mode 100644 src/govoplan_audit.egg-info/requires.txt delete mode 100644 src/govoplan_audit.egg-info/top_level.txt delete mode 100644 src/govoplan_audit/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/govoplan_audit/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/govoplan_audit/backend/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/govoplan_audit/backend/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/govoplan_audit/backend/__pycache__/manifest.cpython-312.pyc delete mode 100644 src/govoplan_audit/backend/__pycache__/manifest.cpython-313.pyc delete mode 100644 src/govoplan_audit/backend/api/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/govoplan_audit/backend/api/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/govoplan_audit/backend/api/v1/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/govoplan_audit/backend/api/v1/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/govoplan_audit/backend/api/v1/__pycache__/routes.cpython-312.pyc delete mode 100644 src/govoplan_audit/backend/api/v1/__pycache__/routes.cpython-313.pyc delete mode 100644 src/govoplan_audit/backend/api/v1/__pycache__/schemas.cpython-312.pyc delete mode 100644 src/govoplan_audit/backend/api/v1/__pycache__/schemas.cpython-313.pyc delete mode 100644 src/govoplan_audit/backend/db/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/govoplan_audit/backend/db/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/govoplan_audit/backend/db/__pycache__/models.cpython-312.pyc delete mode 100644 src/govoplan_audit/backend/db/__pycache__/models.cpython-313.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fd4061 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +build/ +dist/ +node_modules/ +webui/node_modules/ +webui/dist/ +*.tsbuildinfo +.component-test-build/ +.module-test-build/ +.policy-test-build/ +.template-preview-test-build/ +.import-test-build/ +webui/.component-test-build/ +webui/.module-test-build/ +webui/.policy-test-build/ +webui/.template-preview-test-build/ +webui/.import-test-build/ diff --git a/README.md b/README.md index 8c50939..bfeca6e 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ `govoplan-audit` owns audit API route contributions during the GovOPlaN module split. -This repository owns the live `audit_log` table while preserving the -historical table name. It provides audit API route contributions and is the -target boundary for future audit sink/export capability work. +This repository owns the live `audit_log` table, audit API route +contributions, and the target boundary for future audit sink/export capability +work. + +See [docs/AUDIT_TRACE_CONTEXT.md](docs/AUDIT_TRACE_CONTEXT.md) for the standard +operational context fields used by admin, installer, and module lifecycle audit +entries. diff --git a/docs/AUDIT_TRACE_CONTEXT.md b/docs/AUDIT_TRACE_CONTEXT.md new file mode 100644 index 0000000..5da20eb --- /dev/null +++ b/docs/AUDIT_TRACE_CONTEXT.md @@ -0,0 +1,82 @@ +# Audit Trace Context + +Operational audit entries should let an administrator answer four questions: + +- who initiated the operation +- what object was changed +- which lifecycle request or run carried the change +- how the entry connects to surrounding request, installer, or worker logs + +The actor, tenant, scope, action, object type, and object ID remain first-class +`audit_log` columns. Additional operational context belongs in `details`. + +## Standard Fields + +Use these fields for admin, module lifecycle, installer, and worker operations: + +- `module_id`: module affected by the operation, when a single module is the + target. +- `request_id`: queued installer/admin request identifier. +- `run_id`: installer/worker run identifier. +- `outcome`: compact state such as `planned`, `queued`, `applied`, + `cancelled`, `failed`, or `completed`. +- `_trace.correlation_id`: request or workflow correlation ID. +- `_trace.causation_id`: event, run, or operation that caused this audit entry. + +Bulk operations may also include concise arrays such as `activated`, +`deactivated`, `mounted`, `planned_modules`, or sanitized `items`. + +## Access Provenance + +Access-sensitive events should include a compact provenance array when the +decision depends on semantic access state. Use `details.access_provenance` with +items shaped like `AccessDecisionProvenance.to_dict()` from +`govoplan_core.core.access`. + +Expected provenance kinds: + +- `identity` +- `account` +- `tenant_membership` +- `organization_unit` +- `function` +- `group` +- `role` +- `right` +- `delegation` +- `policy` +- `system_actor` + +Delegation and acting-in-place events must show both the real actor account and +the represented/delegated account or function assignment. Feature modules +should obtain this shape through `access.explanation` instead of inspecting +access tables directly. + +## Redaction + +Audit detail payloads must not contain credentials, tokens, raw cookies, +authorization headers, private keys, message bodies, uploaded file content, or +other secret material. Use stable IDs, vault references, or package identifiers +instead. + +The compatibility helper in core, `audit_operation_context`, keeps the standard +fields compact and applies the shared audit redaction pass to additional detail +values. Feature modules should follow the same shape even when they later write +through a dedicated audit sink capability. + +## Module Lifecycle Events + +Module install, uninstall, enable, disable, rollback, and package-catalog +acceptance events should include: + +- `object_type`: `module_install_plan`, `module_install_request`, + `module_state`, or another stable lifecycle object type. +- `object_id`: module ID for single-module actions, otherwise `global` or the + request/run ID. +- `details.module_id`, `details.request_id`, or `details.run_id` when present. +- `details.outcome` with the lifecycle result. +- `details._trace` when the operation originated from a request or queued + daemon action. + +This keeps admin UI timelines, audit exports, and rollback diagnostics aligned +without coupling modules to the audit table implementation. diff --git a/src/govoplan_audit.egg-info/PKG-INFO b/src/govoplan_audit.egg-info/PKG-INFO deleted file mode 100644 index c1e5d60..0000000 --- a/src/govoplan_audit.egg-info/PKG-INFO +++ /dev/null @@ -1,18 +0,0 @@ -Metadata-Version: 2.4 -Name: govoplan-audit -Version: 0.1.4 -Summary: GovOPlaN audit platform module. -Author: GovOPlaN -Requires-Python: >=3.12 -Description-Content-Type: text/markdown -Requires-Dist: govoplan-core>=0.1.4 -Requires-Dist: govoplan-access>=0.1.4 - -# GovOPlaN Audit - -`govoplan-audit` owns audit API route contributions during the GovOPlaN module -split. - -This repository owns the live `audit_log` table while preserving the -historical table name. It provides audit API route contributions and is the -target boundary for future audit sink/export capability work. diff --git a/src/govoplan_audit.egg-info/SOURCES.txt b/src/govoplan_audit.egg-info/SOURCES.txt deleted file mode 100644 index 6128ccb..0000000 --- a/src/govoplan_audit.egg-info/SOURCES.txt +++ /dev/null @@ -1,18 +0,0 @@ -README.md -pyproject.toml -src/govoplan_audit/__init__.py -src/govoplan_audit/py.typed -src/govoplan_audit.egg-info/PKG-INFO -src/govoplan_audit.egg-info/SOURCES.txt -src/govoplan_audit.egg-info/dependency_links.txt -src/govoplan_audit.egg-info/entry_points.txt -src/govoplan_audit.egg-info/requires.txt -src/govoplan_audit.egg-info/top_level.txt -src/govoplan_audit/backend/__init__.py -src/govoplan_audit/backend/manifest.py -src/govoplan_audit/backend/api/__init__.py -src/govoplan_audit/backend/api/v1/__init__.py -src/govoplan_audit/backend/api/v1/routes.py -src/govoplan_audit/backend/api/v1/schemas.py -src/govoplan_audit/backend/db/__init__.py -src/govoplan_audit/backend/db/models.py \ No newline at end of file diff --git a/src/govoplan_audit.egg-info/dependency_links.txt b/src/govoplan_audit.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/src/govoplan_audit.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/govoplan_audit.egg-info/entry_points.txt b/src/govoplan_audit.egg-info/entry_points.txt deleted file mode 100644 index 6c4e37e..0000000 --- a/src/govoplan_audit.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[govoplan.modules] -audit = govoplan_audit.backend.manifest:get_manifest diff --git a/src/govoplan_audit.egg-info/requires.txt b/src/govoplan_audit.egg-info/requires.txt deleted file mode 100644 index 58ec5d6..0000000 --- a/src/govoplan_audit.egg-info/requires.txt +++ /dev/null @@ -1,2 +0,0 @@ -govoplan-core>=0.1.4 -govoplan-access>=0.1.4 diff --git a/src/govoplan_audit.egg-info/top_level.txt b/src/govoplan_audit.egg-info/top_level.txt deleted file mode 100644 index e6ae8f4..0000000 --- a/src/govoplan_audit.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -govoplan_audit diff --git a/src/govoplan_audit/__pycache__/__init__.cpython-312.pyc b/src/govoplan_audit/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 502716bb95ba75f8c3e7fef6e5650b064bbbb643..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 193 zcmX@j%ge<81V1{xv*dvEV-N=h7@>^M96-i&h7^V85wUe$P}>vIRHep BGu;3H diff --git a/src/govoplan_audit/__pycache__/__init__.cpython-313.pyc b/src/govoplan_audit/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index d6b96750e90192773e19b439fb60221b07ba5942..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 193 zcmey&%ge<81V1{xv*dvEV-N=h7@>^M96-iYhG2#whIB?vrYbS_{4)Q5oJ2o`#L|?^ z5{2CSl+v73JwHvxTkP@iDf!9q@hcfVgEZf=*3Zo=(RXnSanw)GEYVNTFUv2;NzBs) zYt%0;N`?!?LxkevGxIV_;^XxSDsOSvKuoqPVgs55a!oPFB_Eg>85wUe$P}>vIRHw4 BGq?Z% diff --git a/src/govoplan_audit/backend/__pycache__/__init__.cpython-312.pyc b/src/govoplan_audit/backend/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 34dcb39065709d787e16f9b40fb061aed6e4f274..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 229 zcmX@j%ge<81V1{xv#fyhV-N=h7@>^M96-i&h7^VtksW?&&AV*M4ATWMsU>U{S;Z^M96-iYhG2#whIB?vrYa++#N_PMycC7Zypq)P zqQsKS{5*xU{33;tj8p~p{4)Q5oJ2o`#L|?^5{2CSl+v73JwHvxTkP@iDf!9q@hcfV zgABgqte=}#qVM7u;;5gVS)!kwUzT5xlbELq)~H`xlnfV&hX^G>tksW?&&AV*M4ATWMsU>U{S;Zp{KQO&6-TPdtZ4W|Ke2OHlPou-aR4VSTrE4WIeR>5V`IJiDfzx?AGtQw?vU@dKP z37aSNV?Eb!;_}1V(+KK2Q|T2NbV#tCtq>V%+*~l4n~A=$X@)&W5n0;detmXUO-5@=z(yAz6Fc` z89?b9KGD*_Vi(N0n0q+kz{|gf1zLy65$?d-KW3(=} z>7wlC^(dXmSeEtT&LE2U;O@@-oxwQZgL%ALD&Bxme__f~46afPqN~&N(?ij8RVH`j z`8=VAR1&TNA8WugMKg2dtho6{tv%hn00R;w6#*ji5soe?Y7&tU&X;Nh+Q6CfT&R6JrV0srmCNI(y{a))%KUL0qwoYYWYQh9eGT@*Y zjj6=g?ux4GjxyO<*QHPd6036>y+%tsr0_ODT7{gOUqN9ly#0idzM8I(Q1^>b`4mt? z?p*s%Uq)rsszOzlxb~_m9>VQlgvw`95tP0m1DcA1pCn@{Pp(=(Z%Ap;q!;E^Ud=Y` z!-DKI`wph_f~IL_=$*gN)-x16L;I&_{|w!{Fp65|c@x#wX65Fr(wfz~v-bL|V?FI! zv(DqiM@Sl cZ~r=eZ0?;G^tX)5H(PamYF^&dw4N&PUj${gLI3~& diff --git a/src/govoplan_audit/backend/__pycache__/manifest.cpython-313.pyc b/src/govoplan_audit/backend/__pycache__/manifest.cpython-313.pyc deleted file mode 100644 index f5099c2be42831b6e9bf60b6813412c55f2eb562..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1478 zcmaJ>PjB2r6d!wcZSUXgzX~NJ#iSvU4>(lnB_xVeKpb|PP}N03VkP6XCtHVI+j?U! zv={a(aESN{@C`U{;3G7rZbw8#LL9gyTDbFOHk)mbs3nizd-LYaZ{F`Wo>i+Qg3t5W zgOgwKg#00##nYG@%8a9NN*v-SUD8oNF@>#m)lLrb(#~}Y9SyXO4!ZR7U87TkVy6V9 zP8rHFR_InbRj8ufbhI^cl80Kayh$A0F`S}P>eYHh;q|JIZZ7~VzfQTQZIWL8k-3a5 zEL>mNRh_C++b#6;H)q{boRwW28cxfpOAO6H!)pH}N?8godr=gpUK+*`LsuKc0Ua_+ z<+lTf6F2qxL+S!eL!cuXrEUW8I1DIoGoipjmWtp!D~rO2rQUGp4l)k{E61yc;Q(Z9 zC!s#NagYt^$8nU>Z&O~IyN6yB9#fX$?)i^AMtx;*O8lz$<_J+{Z8T2FedGgKKt>|P zB%do=#L69kND|l~&Qc19eQOYp<7DVXu9pR2YWF?=YZ?W%mxT8Ct}XKzRKzls>;4>% z^qHt-ntYpqzjL$7&Q+D2(G_4jdCF_9Eao12ej3A5sN?b?zO0JFgiMW<^ZefL<;LW2 zh5^D05)d!k7r34kKCtiF_XKp$_bFqK(JTzTJ{>+g`2<@6nV*JZYA*Y43RajYtGpk7 z6Vbp7JZwMq&6F(Q+%Y!6OrKEmiVpOAFG(|?IJrz`k(aP1acWxyUPRiF88-~LF`}s_ zYV!7STbE@?5<^M@-g+&1Er$AQL6j3^md;fymXEv<<@#7iC60I{pb7FoBR`~!S8ueA z*G3DmxJ;;J^*?&LmLBs(D;JMYi&RJrjZ^aFB2G{n$`LlySn%}|0dc)y1qWUk(9~Vb zzr;S@bY<#~l-FWxq`iDR7zu0?tw)gP) z7w*~mR~IT-`&7LwYi;eaPKwr#>o2T>Gwa~j!I^P%kyGE%F5j+K)QR!WwxYBpz<&X9 CEv7^O diff --git a/src/govoplan_audit/backend/api/__pycache__/__init__.cpython-312.pyc b/src/govoplan_audit/backend/api/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 91960e2248f1469296224be37694424eb7cb67ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 168 zcmX@j%ge<81ULJ=vq1D?5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!a@WtzE75mx3~|&? z&n(eT&o9d_$Vtr8O)O0Ta*K>P{wB#AY&>+I)f&o-%5reCLr%KNa~imer{fgzKdgs zqkej3iGF&1S$;uIVxDedX$p{AT$BtKiiZd#B_?O5=B4N-7G&zj$7kkcmc+;F6;$5h fu*uC&Da}c>D`Ewj3$nKu#Q4a}$jDg43}gWS-ykYp diff --git a/src/govoplan_audit/backend/api/v1/__pycache__/__init__.cpython-312.pyc b/src/govoplan_audit/backend/api/v1/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 1524f7288204f07f753490abdc9cb26725c4a051..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 171 zcmX@j%ge<81ULJ=vq1D?5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!^3u=EE75mx3~|&? z&n(eT&o9d_$Vtr8O)O0Ta*K>P{wB#AY&>+I)f&o-%5reCLr%KNa~iCer{fgzKdgs zqkej3iGF&1S$;uIVxDedX$p{AT$BtKiiZd#B_?O5=B4N-7G&y|8S2N!XXa&=#K-Fu iRNmsS$<0qG%}KQ@Vg(uvvbq?=_{hx2$XLV-WB~wYk}Ed= diff --git a/src/govoplan_audit/backend/api/v1/__pycache__/routes.cpython-312.pyc b/src/govoplan_audit/backend/api/v1/__pycache__/routes.cpython-312.pyc deleted file mode 100644 index a02f93d1032de88520d74b832402fe70612d54fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13445 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 Wvb;y&v3E*Q&OKTFkHACzqyGUglNDG1 diff --git a/src/govoplan_audit/backend/api/v1/__pycache__/schemas.cpython-313.pyc b/src/govoplan_audit/backend/api/v1/__pycache__/schemas.cpython-313.pyc deleted file mode 100644 index aed55ce83ba87c5578045db6d32852159d3b0b13..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2315 zcmaJ@U2EHB6nC%-tX)dPRbWJBzOZL>tlJ`n3 z*zCd(SaX?+6?(BBu-)w+>@Tz!iu%IXV2qKwL9&}&?VMMRW0!0KKRV~UU($2VbL69Z zUL|n-yt?)Hrbx)|=$u@c#1M|Y2je+0h#_o}sz8Mj+NsS{HAPe0PH(2GA{DC{n&G~< zDOF`ER~4#Mvou>(sahg)q)d#=JTatQ`9?a~n`%5NLsS`w=6Eyd&>%)dx7nEO#L+Q z>uzJitwk*Jnd^8qE7tL5KD9g-_6TS#dENwpD7=KD1Hk8Gmr%hVG-U`hy$rY&#Kb6;h;@sWs|A;%w83ek6KUL zO~*8uYMRZ!ZhL6wO!Mou;T*y{2%H`f zpNO6ZpUs+Ph&5D$^CRj$YDZ28>o36A5&Q_dTWR`HWn+D3z0z=_N+Wn0v^>lIz(U$9 zA*~HVW)ga2)t)%MU9nnj<>_iAtbGN{h1XggnuGl~;6sEv0MAIQXnS{JeYJP{rFNw+ zuErNjy}SF}zF3ME%e~Em`My|=bH%;Q5qCJ{&En;sHeM~R_3rF{*%#O1iMiL}9KC#U z%vc={L?|5Lx&K$kP+=KfVG>ld2%gM61u7c7q@pD%b1$iAjVg_to@eJyYjMjBqtBhN z1=@5DXW_{1j0d3yr!%lxq4q$d&5)&CALJN;oV`Nccpu;yc{9E6 zgZ*mydT;ADuD_w8;`v1o(-(blF82^McGP-n{q%je_4~x6=ry_bQi4W;}Fkv7K=}b=X_%kW`2nP`Gj1Z|uEiAtl zmy=S*p>4g09l73)+7vo6#g|GtxCGw-*Kbh#3qd6SrnEXXe0AI!l`)1J^2ST?|3}gW zx%_x)_~GN?WXjE?^M67=h85Tl!VQ4G(t;rTLdt)TgC;+>S2_!j%_me-pUzH~JTmv?{g$ diff --git a/src/govoplan_audit/backend/api/v1/routes.py b/src/govoplan_audit/backend/api/v1/routes.py index 671d06b..171e70c 100644 --- a/src/govoplan_audit/backend/api/v1/routes.py +++ b/src/govoplan_audit/backend/api/v1/routes.py @@ -3,20 +3,25 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy import false, func, or_ +from sqlalchemy import and_, false, func, or_ from sqlalchemy.orm import Session -from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_any_scope +from govoplan_access.auth import ApiPrincipal, has_scope, require_any_scope from govoplan_audit.backend.db.models import AuditLog +from govoplan_core.audit.logging import AUDIT_MODULE_ID, AUDIT_SYSTEM_EVENTS_COLLECTION, AUDIT_TENANT_EVENTS_COLLECTION from govoplan_core.core.access import CAPABILITY_ACCESS_ADMINISTRATION, AccessAdministration +from govoplan_core.core.change_sequence import decode_sequence_watermark, encode_sequence_watermark, max_sequence_id, sequence_entries_since, sequence_watermark_is_expired +from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint from govoplan_core.core.runtime import get_registry from govoplan_core.db.session import get_session from govoplan_tenancy.backend.db.models import Tenant -from .schemas import AuditAdminItem, AuditAdminListResponse, AuditLogItemResponse, AuditLogListResponse +from .schemas import AuditAdminDeltaResponse, AuditAdminItem, AuditAdminListResponse, AuditLogItemResponse, AuditLogListResponse router = APIRouter(tags=["audit"]) +AUDIT_ADMIN_CURSOR_SCOPE = "audit.admin" + def _access_administration() -> AccessAdministration: registry = get_registry() @@ -91,7 +96,7 @@ def _audit_time_filter(raw: str | None): try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid audit date filter.") from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid audit date filter.") from exc if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) else: @@ -111,6 +116,176 @@ def _audit_time_filter(raw: str | None): return AuditLog.created_at == parsed +def _audit_delta_collections(effective_scope: str) -> tuple[str, ...]: + if effective_scope == "system": + return (AUDIT_SYSTEM_EVENTS_COLLECTION,) + if effective_scope == "tenant": + return (AUDIT_TENANT_EVENTS_COLLECTION,) + return (AUDIT_TENANT_EVENTS_COLLECTION, AUDIT_SYSTEM_EVENTS_COLLECTION) + + +def _audit_delta_watermark(session: Session, *, effective_scope: str, tenant_id: str | None) -> str: + return encode_sequence_watermark( + max_sequence_id( + session, + tenant_id=tenant_id if effective_scope == "tenant" else None, + module_id=AUDIT_MODULE_ID, + collections=_audit_delta_collections(effective_scope), + ) + ) + + +def _audit_delta_entries( + session: Session, + *, + effective_scope: str, + tenant_id: str | None, + since: str, + limit: int, +): + try: + since_sequence = decode_sequence_watermark(since) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + scoped_tenant_id = tenant_id if effective_scope == "tenant" else None + collections = _audit_delta_collections(effective_scope) + if sequence_watermark_is_expired( + session, + since=since_sequence, + tenant_id=scoped_tenant_id, + module_id=AUDIT_MODULE_ID, + collections=collections, + ): + return None, False + entries_plus_one = sequence_entries_since( + session, + since=since_sequence, + tenant_id=scoped_tenant_id, + module_id=AUDIT_MODULE_ID, + collections=collections, + limit=limit + 1, + ) + has_more = len(entries_plus_one) > limit + return entries_plus_one[:limit], has_more + + +def _audit_delta_response_watermark( + session: Session, + *, + effective_scope: str, + tenant_id: str | None, + entries, + has_more: bool, +) -> str: + return encode_sequence_watermark(entries[-1].id) if has_more and entries else _audit_delta_watermark(session, effective_scope=effective_scope, tenant_id=tenant_id) + + +def _audit_items(session: Session, rows: list[AuditLog], access_admin: AccessAdministration) -> list[AuditAdminItem]: + actor_email_by_user_id = access_admin.actor_email_by_user_id(session, {row.user_id for row in rows if row.user_id}) + return [ + AuditAdminItem( + id=row.id, + scope=row.scope, + tenant_id=row.tenant_id, + actor_email=actor_email_by_user_id.get(row.user_id) if row.user_id else None, + action=row.action, + object_type=row.object_type, + object_id=row.object_id, + details=row.details or {}, + created_at=row.created_at, + ) + for row in rows + ] + + +def _audit_filter_params( + *, + filter_time: str | None, + filter_actor: str | None, + filter_action: str | None, + filter_object: str | None, + filter_tenant: str | None, +) -> dict[str, str]: + return { + "time": filter_time or "", + "actor": filter_actor or "", + "action": filter_action or "", + "object": filter_object or "", + "tenant": filter_tenant or "", + } + + +def _audit_cursor_fingerprint( + *, + effective_scope: str, + tenant_id: str | None, + page_size: int, + sort_by: str, + sort_direction: str, + filters: dict[str, str], +) -> str: + return keyset_query_fingerprint( + AUDIT_ADMIN_CURSOR_SCOPE, + { + "scope": effective_scope, + "tenant_id": tenant_id or "", + "page_size": page_size, + "sort_by": sort_by, + "sort_direction": sort_direction, + "filters": filters, + }, + ) + + +def _audit_sort_value(row: AuditLog, sort_by: str): + if sort_by == "time": + return row.created_at + if sort_by == "actor": + return row.user_id or "System" + if sort_by == "action": + return row.action + if sort_by == "object": + return f"{row.object_type or ''} {row.object_id or ''}" + if sort_by == "tenant": + return row.tenant_id or "" + raise KeysetCursorError("Unsupported audit sort column") + + +def _audit_cursor_for_row(row: AuditLog, *, sort_by: str, sort_direction: str, fingerprint: str) -> str: + return encode_keyset_cursor( + AUDIT_ADMIN_CURSOR_SCOPE, + fingerprint=fingerprint, + values={ + "id": row.id, + "sort_by": sort_by, + "sort_direction": sort_direction, + "sort_value": _audit_sort_value(row, sort_by), + }, + ) + + +def _audit_decode_sort_value(sort_by: str, value): + if sort_by == "time": + if not isinstance(value, str): + raise KeysetCursorError("Invalid pagination cursor") + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise KeysetCursorError("Invalid pagination cursor") from exc + if value is None: + return "" + return str(value) + + +def _audit_cursor_condition(sort_column, *, sort_by: str, sort_direction: str, cursor_values: dict[str, object]): + cursor_id = cursor_values.get("id") + if not isinstance(cursor_id, str) or not cursor_id: + raise KeysetCursorError("Invalid pagination cursor") + sort_value = _audit_decode_sort_value(sort_by, cursor_values.get("sort_value")) + primary_after = sort_column > sort_value if sort_direction == "asc" else sort_column < sort_value + return or_(primary_after, and_(sort_column == sort_value, AuditLog.id < cursor_id)) + + @router.get("/admin/audit", response_model=AuditAdminListResponse) def list_admin_audit( tenant_id: str | None = Query(default=None), @@ -120,6 +295,7 @@ def list_admin_audit( offset: int = Query(default=0, ge=0), page: int | None = Query(default=None, ge=1), page_size: int | None = Query(default=None, ge=1, le=500), + cursor: str | None = Query(default=None), sort_by: str = Query(default="time"), sort_direction: str = Query(default="desc"), filter_time: str | None = Query(default=None), @@ -132,13 +308,14 @@ def list_admin_audit( ): effective_scope = audit_scope or ("all" if all_tenants else "tenant") if effective_scope not in {"tenant", "system", "all"}: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Audit scope must be tenant, system or all.") + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Audit scope must be tenant, system or all.") if sort_by not in {"time", "actor", "action", "object", "tenant"}: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unsupported audit sort column.") + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Unsupported audit sort column.") if sort_direction not in {"asc", "desc"}: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Audit sort direction must be asc or desc.") + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Audit sort direction must be asc or desc.") query = session.query(AuditLog) + resolved_tenant_id: str | None = None if effective_scope != "all": query = query.filter(AuditLog.scope == effective_scope) if effective_scope == "system": @@ -151,6 +328,7 @@ def list_admin_audit( if not has_scope(principal, "audit:read"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: audit:read") tenant = _resolve_tenant(session, principal, tenant_id) + resolved_tenant_id = tenant.id query = query.filter(AuditLog.tenant_id == tenant.id) object_text = func.coalesce(AuditLog.object_type, "") + " " + func.coalesce(AuditLog.object_id, "") @@ -166,15 +344,134 @@ def list_admin_audit( if condition is not None: query = query.filter(condition) + sort_columns = { + "time": AuditLog.created_at, + "actor": func.coalesce(AuditLog.user_id, "System"), + "action": AuditLog.action, + "object": object_text, + "tenant": func.coalesce(AuditLog.tenant_id, ""), + } + sort_column = sort_columns[sort_by] + order = sort_column.asc() if sort_direction == "asc" else sort_column.desc() + ordered_query = query.order_by(order, AuditLog.id.desc()) total = query.count() effective_page_size = page_size or limit pages = max(1, (total + effective_page_size - 1) // effective_page_size) - if page is not None or page_size is not None: - effective_page = min(page or 1, pages) - effective_offset = (effective_page - 1) * effective_page_size + filters = _audit_filter_params( + filter_time=filter_time, + filter_actor=filter_actor, + filter_action=filter_action, + filter_object=filter_object, + filter_tenant=filter_tenant, + ) + fingerprint = _audit_cursor_fingerprint( + effective_scope=effective_scope, + tenant_id=resolved_tenant_id, + page_size=effective_page_size, + sort_by=sort_by, + sort_direction=sort_direction, + filters=filters, + ) + + start_cursor: str | None = None + if cursor: + try: + cursor_values = decode_keyset_cursor(AUDIT_ADMIN_CURSOR_SCOPE, cursor, fingerprint=fingerprint) + if cursor_values is None: + raise KeysetCursorError("Invalid pagination cursor") + page_query = query.filter(_audit_cursor_condition(sort_column, sort_by=sort_by, sort_direction=sort_direction, cursor_values=cursor_values)) + except KeysetCursorError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + effective_page = page or (offset // effective_page_size + 1) + effective_offset = 0 + start_cursor = cursor else: - effective_page = offset // effective_page_size + 1 - effective_offset = offset + if page is not None or page_size is not None: + effective_page = min(page or 1, pages) + effective_offset = (effective_page - 1) * effective_page_size + else: + effective_page = offset // effective_page_size + 1 + effective_offset = offset + page_query = query + if effective_offset > 0: + previous_row = ordered_query.offset(effective_offset - 1).limit(1).first() + if previous_row is not None: + start_cursor = _audit_cursor_for_row(previous_row, sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint) + + rows_plus_one = page_query.order_by(order, AuditLog.id.desc()).offset(effective_offset).limit(effective_page_size + 1).all() + rows = rows_plus_one[:effective_page_size] + next_cursor = ( + _audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint) + if len(rows_plus_one) > effective_page_size and rows else None + ) + + return AuditAdminListResponse( + total=total, + page=effective_page, + page_size=effective_page_size, + pages=pages, + cursor=start_cursor, + next_cursor=next_cursor, + items=_audit_items(session, rows, access_admin), + ) + + +@router.get("/admin/audit/delta", response_model=AuditAdminDeltaResponse) +def list_admin_audit_delta( + tenant_id: str | None = Query(default=None), + all_tenants: bool = Query(default=False), + audit_scope: str | None = Query(default=None, alias="scope"), + limit: int = Query(default=100, ge=1, le=500), + page_size: int | None = Query(default=None, ge=1, le=500), + cursor: str | None = Query(default=None), + sort_by: str = Query(default="time"), + sort_direction: str = Query(default="desc"), + filter_time: str | None = Query(default=None), + filter_actor: str | None = Query(default=None), + filter_action: str | None = Query(default=None), + filter_object: str | None = Query(default=None), + filter_tenant: str | None = Query(default=None), + since: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("audit:read", "system:audit:read")), +): + effective_scope = audit_scope or ("all" if all_tenants else "tenant") + if effective_scope not in {"tenant", "system", "all"}: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Audit scope must be tenant, system or all.") + if sort_by not in {"time", "actor", "action", "object", "tenant"}: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Unsupported audit sort column.") + if sort_direction not in {"asc", "desc"}: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Audit sort direction must be asc or desc.") + + query = session.query(AuditLog) + resolved_tenant_id: str | None = None + if effective_scope != "all": + query = query.filter(AuditLog.scope == effective_scope) + if effective_scope == "system": + if not has_scope(principal, "system:audit:read"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:audit:read") + elif effective_scope == "all" or all_tenants: + if not has_scope(principal, "system:audit:read"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:audit:read") + else: + if not has_scope(principal, "audit:read"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: audit:read") + tenant = _resolve_tenant(session, principal, tenant_id) + resolved_tenant_id = tenant.id + query = query.filter(AuditLog.tenant_id == tenant.id) + + object_text = func.coalesce(AuditLog.object_type, "") + " " + func.coalesce(AuditLog.object_id, "") + access_admin = _access_administration() + filters = [ + _audit_time_filter(filter_time), + _audit_actor_filter(access_admin, session, filter_actor), + _audit_text_filter(AuditLog.action, filter_action), + _audit_text_filter(object_text, filter_object), + _audit_text_filter(AuditLog.tenant_id, filter_tenant), + ] + for condition in filters: + if condition is not None: + query = query.filter(condition) sort_columns = { "time": AuditLog.created_at, @@ -185,28 +482,108 @@ def list_admin_audit( } sort_column = sort_columns[sort_by] order = sort_column.asc() if sort_direction == "asc" else sort_column.desc() - rows = query.order_by(order, AuditLog.id.desc()).offset(effective_offset).limit(effective_page_size).all() - actor_email_by_user_id = access_admin.actor_email_by_user_id(session, {row.user_id for row in rows if row.user_id}) + total = query.count() + effective_page_size = page_size or limit + pages = max(1, (total + effective_page_size - 1) // effective_page_size) + filters = _audit_filter_params( + filter_time=filter_time, + filter_actor=filter_actor, + filter_action=filter_action, + filter_object=filter_object, + filter_tenant=filter_tenant, + ) + fingerprint = _audit_cursor_fingerprint( + effective_scope=effective_scope, + tenant_id=resolved_tenant_id, + page_size=effective_page_size, + sort_by=sort_by, + sort_direction=sort_direction, + filters=filters, + ) + start_cursor: str | None = None + page_query = query + if cursor: + try: + cursor_values = decode_keyset_cursor(AUDIT_ADMIN_CURSOR_SCOPE, cursor, fingerprint=fingerprint) + if cursor_values is None: + raise KeysetCursorError("Invalid pagination cursor") + page_query = query.filter(_audit_cursor_condition(sort_column, sort_by=sort_by, sort_direction=sort_direction, cursor_values=cursor_values)) + start_cursor = cursor + except KeysetCursorError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - return AuditAdminListResponse( + if since is None: + rows_plus_one = page_query.order_by(order, AuditLog.id.desc()).limit(effective_page_size + 1).all() + rows = rows_plus_one[:effective_page_size] + next_cursor = ( + _audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint) + if len(rows_plus_one) > effective_page_size and rows else None + ) + return AuditAdminDeltaResponse( + total=total, + page=1, + page_size=effective_page_size, + pages=pages, + cursor=start_cursor, + next_cursor=next_cursor, + items=_audit_items(session, rows, access_admin), + deleted=[], + watermark=_audit_delta_watermark(session, effective_scope=effective_scope, tenant_id=resolved_tenant_id), + has_more=False, + full=True, + ) + + entries, has_more = _audit_delta_entries( + session, + effective_scope=effective_scope, + tenant_id=resolved_tenant_id, + since=since, + limit=effective_page_size, + ) + if entries is None: + rows_plus_one = page_query.order_by(order, AuditLog.id.desc()).limit(effective_page_size + 1).all() + rows = rows_plus_one[:effective_page_size] + next_cursor = ( + _audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint) + if len(rows_plus_one) > effective_page_size and rows else None + ) + return AuditAdminDeltaResponse( + total=total, + page=1, + page_size=effective_page_size, + pages=pages, + cursor=start_cursor, + next_cursor=next_cursor, + items=_audit_items(session, rows, access_admin), + deleted=[], + watermark=_audit_delta_watermark(session, effective_scope=effective_scope, tenant_id=resolved_tenant_id), + has_more=False, + full=True, + ) + + changed_ids = [entry.resource_id for entry in entries if entry.resource_type == "audit_log"] + rows = ( + page_query.filter(AuditLog.id.in_(changed_ids)).order_by(order, AuditLog.id.desc()).limit(effective_page_size).all() + if changed_ids else [] + ) + return AuditAdminDeltaResponse( total=total, - page=effective_page, + page=1, page_size=effective_page_size, pages=pages, - items=[ - AuditAdminItem( - id=row.id, - scope=row.scope, - tenant_id=row.tenant_id, - actor_email=actor_email_by_user_id.get(row.user_id) if row.user_id else None, - action=row.action, - object_type=row.object_type, - object_id=row.object_id, - details=row.details or {}, - created_at=row.created_at, - ) - for row in rows - ], + cursor=start_cursor, + next_cursor=None, + items=_audit_items(session, rows, access_admin), + deleted=[], + watermark=_audit_delta_response_watermark( + session, + effective_scope=effective_scope, + tenant_id=resolved_tenant_id, + entries=entries, + has_more=has_more, + ), + has_more=has_more, + full=False, ) diff --git a/src/govoplan_audit/backend/api/v1/schemas.py b/src/govoplan_audit/backend/api/v1/schemas.py index 2e5f6f6..0ed3566 100644 --- a/src/govoplan_audit/backend/api/v1/schemas.py +++ b/src/govoplan_audit/backend/api/v1/schemas.py @@ -5,6 +5,8 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator +from govoplan_core.api.v1.schemas import DeltaDeletedItem + class AuditAdminItem(BaseModel): id: str @@ -24,6 +26,15 @@ class AuditAdminListResponse(BaseModel): page: int = 1 page_size: int = 100 pages: int = 1 + cursor: str | None = None + next_cursor: str | None = None + + +class AuditAdminDeltaResponse(AuditAdminListResponse): + deleted: list[DeltaDeletedItem] = Field(default_factory=list) + watermark: str | None = None + has_more: bool = False + full: bool = False class AuditLogItemResponse(BaseModel): diff --git a/src/govoplan_audit/backend/db/__pycache__/__init__.cpython-312.pyc b/src/govoplan_audit/backend/db/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index da341371eee4ab5986a90b50ea29f0f886931be3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 225 zcmX@j%ge<81RTshS;j#6F^B^Lj8MjB4j^MXLkdF_LnWgoQ}zQ`1q9!pFt-6a?{Vv zE75mx3~|&?&n(eT&o9d_$Vtr8O#~UMUtE+77m9}nB_$?jr{<;TrzGje$7kkcmc+;F l6;%G>umM_9nv-f*#0E4L}zQ`1q9!pFt+xa?{Vv zE75mx3~|&?&n(eT&o9d_$Vtr8O#~UMUtE+77m9}nB_$?jr{<;TrzGje$7kkcmc+;F l6;$5humM_9nv-f*#0E4L7fK6rQ!$_S);8{1FxcJDShe*AwWFP8LQB`rMxKV`@r_S58^P`HYEBX2Td^7XS zyf<%tjm08}e;xkGrRM>JUW3v2f}SF5Kjg||gb@}DWD1HPfbKJVijV*M4ZkTWqU#fl zfEiSRW=IK{k|LR5CG7eGM#PjA*^DYt*A5ynGp@wVgp%O4j6)AarOAh`AS^vZI85Rs zu`YV6E6q5JBa1$zrQl0N-+(hEFj=##jIG&v#>!1`+dpg-QX-3tWhl`XtqD?KfvXlK zD@+`pxjw~$Gd9(&#gvZ)uW8vV!7OIF|7tp8UYHLs=b5DO3D`9U*-iIE$~7MNs_ zWfkV7ZG6%w|K(b_D52lw_>cBa#qWzh+^@);j@U_CxPRYHL)^hXVfz=(`7t6$5inAG zH+Tgoejg$t_F+F3*X#RsJpmkaef1Mq@F@ZA5At6KhZ=jw5|K8-jd2RkEQw$l&T|JF z-ND9i97`L!l_0xl{~mM{H{s@umUW*?msbzRx9-dy!)<)_;6D5T-2Op)+{GWKFM^3!gx zB`a?j+PpznK!;mhjCg?D0KYt;%fmC5het+v8;~+QY7m>GeDnw(NMZ3}%kCOI_j{%? z7MUwb`5d7zyeQ3#&h|{*n4DxG@TuG-Ygrv=y3rv8qggUKgjYiFL0mq)gaSKDVwmsaJ9I8*EBdvb4W{%NuN z`4(|*s@0C$rP0+374dd0`RV3~^4HGnT=mQ?M>m~(p_(j~t~!as6|q?B9o&qR&pXpf z_4G|=VacKQs=dplD^9GtA}-gCp4mKKHk~hTSI=sWk#$z?SC2j@jXO=f74bps(6ML1 zwcNT~CR;<*L(`?P)rpEYUF$o$IaWUVV&bJ;?VBu3IL${Y;v~@ME4RKl`Es_J9Ir1w zUh6+!9@(^2C#vFg@1Wc>f+WDhLy4WI7L9}13MpR5a|cf`g*HkPT=2p#WcfQq zp^36c?yjL3>2HWx@G=G2_7VcwW%1qkK!%z$3}HwV!Z diff --git a/src/govoplan_audit/backend/db/__pycache__/models.cpython-313.pyc b/src/govoplan_audit/backend/db/__pycache__/models.cpython-313.pyc deleted file mode 100644 index 33aba81c8902d0a8fc49acf636ffdf1c32a38478..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2151 zcmaJ?-ER{|5Wlm}_Sxr;d_z)Ff^h-{5>rTM168F!k_tP7sFzl1<8Ykqi+kaGHhX6h zTOheo)uOgidEpO0yoC0p5ifq^F^^|is_IoyRq;^ejVk;Bow>6EDQZ``xt*Ec&dl!2 z&hCXm0S3mPdtv=255v4brZc?Ggx&iFCJz~r5!n=zWC=Thb63hmT<~|N+)0jbwv0=8 zl3wCX`iL*d6F%uDep~KI1(E_0l0g!*d2cF|43ls&LL$HmqHl;<=SbIKCd!EX5F`52 z;dEq*bNrKT(JuxPF4B{C#e=&jjI&gbb-ifHrdrgEMc~~tdL_=$zNZS5xpG;}Q4i9| zC(Ir~>1?8)n~B+(<(WiYH52*bonlFo^>MkJ1J)qfeUapdGII8oqURF1OrlWCDVi}+ zs!(26HYBu5`oL%qKBE^%jrm;&|6u-D{jU1`-KKEF;*O9WkRRBq4+JpSy{{qWhfJCw ztjIvdS>hgr>=Q2Sfo!=&PV`K{nc%P`UeRaE+UGFoBHpww%A|SBZD;?GVaVyUn*>B5 zEr`JrxVnQ1?W4kC1ajXs&5@vu zhYw%||ApzYF+=+pjQpMbm@p#r)@?_0Lsn1r^D)|QwYdOMz&Z|q%2WnQ^`rPNjo(%&Oqrsud{cRq6sb zRvh&@%je^6GJ-s>mo-h!XbSbHcsZ*92VhLV4e(u=5wFb5&cSWwi=fpMQ;E9>`X!@4 zsy(lLjNBbrEb7W?l`k6#!R1w6oLe4Q{B&V~`cNi;NRUe^hH0A*v>8zB<}etqoW=GL zXKm|LC}!4`tSOn5l0yA$M$Lg5Bd89yo#)i7iMu>A0v%_y69*oIDXW@6x&gcxKSX&+ z(&d68Ni-lya3#wc&V!P4yDV#MO+=E`RAQK#sw;XC)FDYS@pv3P!46ErZJ8vKydg;h z8#dvQ;1QE!hyg=y^g{p*p$Zy0Lxz!@L=s1G8p#NrJ9ts#&NPXqB0= zsh~I9j@=vxb`CPoAy|M$5L&~x%x`^1YuEndLsocvYoi`)@^dX@CV#p0?ERgECja?M zk9)vdTl_n~9Ev@f-#Yg=S)biG*%%OOm+uQrPHYX1J-V}%d0ef3u%lQjQe*IDZSMZ1 zCU>(HdvE(xJ#8(oHqPC!)Phy6G-B1-RV#9=$yHm?#CD*5(OUYVadyR8TerxaMs%b0 zu@ySn9DXNvkW`&z4z(qXUmOPvh6