pas.plugins.identity 1.0.0a1__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.
Files changed (235) hide show
  1. pas/plugins/identity/__init__.py +14 -0
  2. pas/plugins/identity/configure.zcml +43 -0
  3. pas/plugins/identity/core/__init__.py +44 -0
  4. pas/plugins/identity/core/audit/__init__.py +388 -0
  5. pas/plugins/identity/core/audit/configure.zcml +36 -0
  6. pas/plugins/identity/core/audit/logsink.py +98 -0
  7. pas/plugins/identity/core/audit/subscribers.py +107 -0
  8. pas/plugins/identity/core/behaviors/__init__.py +11 -0
  9. pas/plugins/identity/core/behaviors/configure.zcml +40 -0
  10. pas/plugins/identity/core/behaviors/membership.py +100 -0
  11. pas/plugins/identity/core/behaviors/password.py +146 -0
  12. pas/plugins/identity/core/browser/__init__.py +1 -0
  13. pas/plugins/identity/core/browser/configure.zcml +18 -0
  14. pas/plugins/identity/core/browser/logout.py +107 -0
  15. pas/plugins/identity/core/catalog.py +278 -0
  16. pas/plugins/identity/core/completeness.py +234 -0
  17. pas/plugins/identity/core/configure.zcml +24 -0
  18. pas/plugins/identity/core/container.py +370 -0
  19. pas/plugins/identity/core/contents/__init__.py +13 -0
  20. pas/plugins/identity/core/contents/configure.zcml +22 -0
  21. pas/plugins/identity/core/contents/group.py +127 -0
  22. pas/plugins/identity/core/contents/profile.py +312 -0
  23. pas/plugins/identity/core/controlpanel/__init__.py +931 -0
  24. pas/plugins/identity/core/controlpanel/configure.zcml +25 -0
  25. pas/plugins/identity/core/controlpanel/controlpanel.py +39 -0
  26. pas/plugins/identity/core/controlpanel/interfaces.py +518 -0
  27. pas/plugins/identity/core/controlpanel/view.py +33 -0
  28. pas/plugins/identity/core/doctor.py +241 -0
  29. pas/plugins/identity/core/drivers/__init__.py +33 -0
  30. pas/plugins/identity/core/drivers/base.py +224 -0
  31. pas/plugins/identity/core/drivers/configure.zcml +37 -0
  32. pas/plugins/identity/core/drivers/emaillink.py +67 -0
  33. pas/plugins/identity/core/drivers/github.py +172 -0
  34. pas/plugins/identity/core/drivers/google.py +21 -0
  35. pas/plugins/identity/core/drivers/identity.py +69 -0
  36. pas/plugins/identity/core/drivers/oidc.py +20 -0
  37. pas/plugins/identity/core/drivers/settings.py +375 -0
  38. pas/plugins/identity/core/events/__init__.py +197 -0
  39. pas/plugins/identity/core/flows/__init__.py +571 -0
  40. pas/plugins/identity/core/flows/magiclink.py +243 -0
  41. pas/plugins/identity/core/flows/metadata.py +229 -0
  42. pas/plugins/identity/core/flows/session.py +205 -0
  43. pas/plugins/identity/core/indexers/__init__.py +108 -0
  44. pas/plugins/identity/core/indexers/configure.zcml +50 -0
  45. pas/plugins/identity/core/interfaces.py +481 -0
  46. pas/plugins/identity/core/localroles.py +74 -0
  47. pas/plugins/identity/core/logout.py +247 -0
  48. pas/plugins/identity/core/pas/__init__.py +16 -0
  49. pas/plugins/identity/core/pas/configure.zcml +13 -0
  50. pas/plugins/identity/core/pas/plugin.py +1400 -0
  51. pas/plugins/identity/core/pas/profile.py +780 -0
  52. pas/plugins/identity/core/patches.py +81 -0
  53. pas/plugins/identity/core/portraits.py +337 -0
  54. pas/plugins/identity/core/serializers/__init__.py +5 -0
  55. pas/plugins/identity/core/serializers/configure.zcml +12 -0
  56. pas/plugins/identity/core/serializers/user.py +124 -0
  57. pas/plugins/identity/core/services/__init__.py +19 -0
  58. pas/plugins/identity/core/services/auditlog/__init__.py +16 -0
  59. pas/plugins/identity/core/services/auditlog/configure.zcml +18 -0
  60. pas/plugins/identity/core/services/auditlog/get.py +84 -0
  61. pas/plugins/identity/core/services/base.py +37 -0
  62. pas/plugins/identity/core/services/callback/__init__.py +1 -0
  63. pas/plugins/identity/core/services/callback/configure.zcml +19 -0
  64. pas/plugins/identity/core/services/callback/post.py +355 -0
  65. pas/plugins/identity/core/services/configure.zcml +67 -0
  66. pas/plugins/identity/core/services/groups/__init__.py +126 -0
  67. pas/plugins/identity/core/services/groups/configure.zcml +20 -0
  68. pas/plugins/identity/core/services/groups/get.py +125 -0
  69. pas/plugins/identity/core/services/identities/__init__.py +85 -0
  70. pas/plugins/identity/core/services/identities/configure.zcml +36 -0
  71. pas/plugins/identity/core/services/identities/delete.py +39 -0
  72. pas/plugins/identity/core/services/identities/get.py +84 -0
  73. pas/plugins/identity/core/services/identities/post.py +178 -0
  74. pas/plugins/identity/core/services/jwt.py +48 -0
  75. pas/plugins/identity/core/services/login/__init__.py +50 -0
  76. pas/plugins/identity/core/services/login/configure.zcml +25 -0
  77. pas/plugins/identity/core/services/login/expander.py +47 -0
  78. pas/plugins/identity/core/services/login/get.py +128 -0
  79. pas/plugins/identity/core/services/magiclink/__init__.py +151 -0
  80. pas/plugins/identity/core/services/magiclink/configure.zcml +28 -0
  81. pas/plugins/identity/core/services/magiclink/confirm.py +200 -0
  82. pas/plugins/identity/core/services/magiclink/post.py +69 -0
  83. pas/plugins/identity/core/services/myprofile.py +114 -0
  84. pas/plugins/identity/core/services/providers/__init__.py +104 -0
  85. pas/plugins/identity/core/services/providers/configure.zcml +55 -0
  86. pas/plugins/identity/core/services/providers/delete.py +35 -0
  87. pas/plugins/identity/core/services/providers/drivers.py +51 -0
  88. pas/plugins/identity/core/services/providers/get.py +38 -0
  89. pas/plugins/identity/core/services/providers/patch.py +97 -0
  90. pas/plugins/identity/core/services/providers/post.py +121 -0
  91. pas/plugins/identity/core/services/schema.py +44 -0
  92. pas/plugins/identity/core/services/types.py +105 -0
  93. pas/plugins/identity/core/services/useraccount/__init__.py +134 -0
  94. pas/plugins/identity/core/services/useraccount/configure.zcml +19 -0
  95. pas/plugins/identity/core/services/useraccount/get.py +139 -0
  96. pas/plugins/identity/core/services/users.py +218 -0
  97. pas/plugins/identity/core/store/__init__.py +244 -0
  98. pas/plugins/identity/core/subscribers/__init__.py +597 -0
  99. pas/plugins/identity/core/subscribers/configure.zcml +102 -0
  100. pas/plugins/identity/core/subscribers/gate.py +303 -0
  101. pas/plugins/identity/core/subscribers/principals.py +205 -0
  102. pas/plugins/identity/core/txn.py +150 -0
  103. pas/plugins/identity/core/utils/__init__.py +11 -0
  104. pas/plugins/identity/core/utils/emails.py +126 -0
  105. pas/plugins/identity/core/utils/flags.py +68 -0
  106. pas/plugins/identity/core/utils/groupmap.py +77 -0
  107. pas/plugins/identity/core/utils/nesting.py +125 -0
  108. pas/plugins/identity/core/utils/propertymap.py +66 -0
  109. pas/plugins/identity/core/utils/svg.py +305 -0
  110. pas/plugins/identity/core/verification.py +169 -0
  111. pas/plugins/identity/core/versioning.py +176 -0
  112. pas/plugins/identity/core/vocabularies/__init__.py +45 -0
  113. pas/plugins/identity/core/vocabularies/auditsinks.py +54 -0
  114. pas/plugins/identity/core/vocabularies/configure.zcml +21 -0
  115. pas/plugins/identity/core/vocabularies/groups.py +52 -0
  116. pas/plugins/identity/core/vocabularies/userfields.py +49 -0
  117. pas/plugins/identity/dependencies.zcml +16 -0
  118. pas/plugins/identity/exportimport/__init__.py +56 -0
  119. pas/plugins/identity/exportimport/authomatic.py +273 -0
  120. pas/plugins/identity/exportimport/cli.py +183 -0
  121. pas/plugins/identity/exportimport/exporter.py +168 -0
  122. pas/plugins/identity/exportimport/importer.py +445 -0
  123. pas/plugins/identity/exportimport/schema.py +254 -0
  124. pas/plugins/identity/interfaces.py +7 -0
  125. pas/plugins/identity/locales/__init__.py +0 -0
  126. pas/plugins/identity/locales/__main__.py +69 -0
  127. pas/plugins/identity/locales/de/LC_MESSAGES/pas.plugins.identity.mo +0 -0
  128. pas/plugins/identity/locales/de/LC_MESSAGES/pas.plugins.identity.po +39 -0
  129. pas/plugins/identity/locales/en/LC_MESSAGES/pas.plugins.identity.mo +0 -0
  130. pas/plugins/identity/locales/en/LC_MESSAGES/pas.plugins.identity.po +39 -0
  131. pas/plugins/identity/locales/es/LC_MESSAGES/pas.plugins.identity.mo +0 -0
  132. pas/plugins/identity/locales/es/LC_MESSAGES/pas.plugins.identity.po +39 -0
  133. pas/plugins/identity/locales/it/LC_MESSAGES/pas.plugins.identity.mo +0 -0
  134. pas/plugins/identity/locales/it/LC_MESSAGES/pas.plugins.identity.po +39 -0
  135. pas/plugins/identity/locales/pas.plugins.identity.pot +42 -0
  136. pas/plugins/identity/locales/pt_BR/LC_MESSAGES/pas.plugins.identity.mo +0 -0
  137. pas/plugins/identity/locales/pt_BR/LC_MESSAGES/pas.plugins.identity.po +39 -0
  138. pas/plugins/identity/migration/__init__.py +103 -0
  139. pas/plugins/identity/migration/authomatic.py +240 -0
  140. pas/plugins/identity/migration/oidc.py +242 -0
  141. pas/plugins/identity/permissions.zcml +53 -0
  142. pas/plugins/identity/profiles/default/browserlayer.xml +6 -0
  143. pas/plugins/identity/profiles/default/controlpanel.xml +26 -0
  144. pas/plugins/identity/profiles/default/metadata.xml +8 -0
  145. pas/plugins/identity/profiles/default/registry/pas.plugins.identity.core.controlpanel.interfaces.IIdentitySettings.xml +32 -0
  146. pas/plugins/identity/profiles/default/registry/pas.plugins.identity.core.controlpanel.interfaces.IProfileSettings.xml +60 -0
  147. pas/plugins/identity/profiles/default/repositorytool.xml +26 -0
  148. pas/plugins/identity/profiles/default/rolemap.xml +71 -0
  149. pas/plugins/identity/profiles/default/toolset.xml +10 -0
  150. pas/plugins/identity/profiles/default/types/UserGroup.xml +60 -0
  151. pas/plugins/identity/profiles/default/types/UserProfile.xml +77 -0
  152. pas/plugins/identity/profiles/default/types.xml +11 -0
  153. pas/plugins/identity/profiles/default/workflows/user_group_workflow/definition.xml +182 -0
  154. pas/plugins/identity/profiles/default/workflows/user_profile_workflow/definition.xml +491 -0
  155. pas/plugins/identity/profiles/default/workflows.xml +19 -0
  156. pas/plugins/identity/profiles/rebuild-catalog/metadata.xml +7 -0
  157. pas/plugins/identity/profiles/uninstall/browserlayer.xml +6 -0
  158. pas/plugins/identity/profiles/uninstall/controlpanel.xml +11 -0
  159. pas/plugins/identity/profiles/uninstall/registry.xml +85 -0
  160. pas/plugins/identity/profiles/uninstall/toolset.xml +7 -0
  161. pas/plugins/identity/profiles/uninstall/types.xml +11 -0
  162. pas/plugins/identity/profiles/uninstall/workflows.xml +11 -0
  163. pas/plugins/identity/profiles.zcml +51 -0
  164. pas/plugins/identity/py.typed +0 -0
  165. pas/plugins/identity/server/__init__.py +10 -0
  166. pas/plugins/identity/server/browser/__init__.py +6 -0
  167. pas/plugins/identity/server/browser/authorize.py +471 -0
  168. pas/plugins/identity/server/browser/configure.zcml +68 -0
  169. pas/plugins/identity/server/browser/discovery.py +123 -0
  170. pas/plugins/identity/server/browser/templates/consent.pt +108 -0
  171. pas/plugins/identity/server/browser/token.py +375 -0
  172. pas/plugins/identity/server/browser/userinfo.py +94 -0
  173. pas/plugins/identity/server/claims.py +237 -0
  174. pas/plugins/identity/server/configure.zcml +27 -0
  175. pas/plugins/identity/server/consent/__init__.py +146 -0
  176. pas/plugins/identity/server/consent/screen.py +41 -0
  177. pas/plugins/identity/server/controlpanel/__init__.py +13 -0
  178. pas/plugins/identity/server/controlpanel/clients.py +560 -0
  179. pas/plugins/identity/server/controlpanel/configure.zcml +16 -0
  180. pas/plugins/identity/server/controlpanel/interfaces.py +449 -0
  181. pas/plugins/identity/server/controlpanel/panel.py +47 -0
  182. pas/plugins/identity/server/discovery.py +116 -0
  183. pas/plugins/identity/server/grants/__init__.py +8 -0
  184. pas/plugins/identity/server/grants/codes.py +284 -0
  185. pas/plugins/identity/server/grants/refresh.py +272 -0
  186. pas/plugins/identity/server/grants/tokens.py +276 -0
  187. pas/plugins/identity/server/interfaces.py +136 -0
  188. pas/plugins/identity/server/pas.py +206 -0
  189. pas/plugins/identity/server/profiles/default/browserlayer.xml +6 -0
  190. pas/plugins/identity/server/profiles/default/controlpanel.xml +30 -0
  191. pas/plugins/identity/server/profiles/default/metadata.xml +7 -0
  192. pas/plugins/identity/server/profiles/default/registry/pas.plugins.identity.server.interfaces.IServerSettings.xml +31 -0
  193. pas/plugins/identity/server/profiles/uninstall/browserlayer.xml +7 -0
  194. pas/plugins/identity/server/profiles/uninstall/controlpanel.xml +9 -0
  195. pas/plugins/identity/server/profiles/uninstall/metadata.xml +4 -0
  196. pas/plugins/identity/server/profiles/uninstall/registry.xml +21 -0
  197. pas/plugins/identity/server/profiles.zcml +31 -0
  198. pas/plugins/identity/server/services/__init__.py +91 -0
  199. pas/plugins/identity/server/services/clients/__init__.py +35 -0
  200. pas/plugins/identity/server/services/clients/delete.py +35 -0
  201. pas/plugins/identity/server/services/clients/get.py +40 -0
  202. pas/plugins/identity/server/services/clients/patch.py +55 -0
  203. pas/plugins/identity/server/services/clients/post.py +92 -0
  204. pas/plugins/identity/server/services/configure.zcml +108 -0
  205. pas/plugins/identity/server/services/consent/__init__.py +20 -0
  206. pas/plugins/identity/server/services/consent/get.py +107 -0
  207. pas/plugins/identity/server/services/grants/__init__.py +22 -0
  208. pas/plugins/identity/server/services/grants/delete.py +96 -0
  209. pas/plugins/identity/server/services/grants/get.py +82 -0
  210. pas/plugins/identity/server/services/keys.py +97 -0
  211. pas/plugins/identity/server/setuphandlers.py +159 -0
  212. pas/plugins/identity/server/subscribers/__init__.py +45 -0
  213. pas/plugins/identity/server/subscribers/configure.zcml +11 -0
  214. pas/plugins/identity/server/utils/__init__.py +6 -0
  215. pas/plugins/identity/server/utils/keys.py +150 -0
  216. pas/plugins/identity/server/utils/session.py +155 -0
  217. pas/plugins/identity/server/vocabularies/__init__.py +10 -0
  218. pas/plugins/identity/server/vocabularies/configure.zcml +9 -0
  219. pas/plugins/identity/server/vocabularies/scopes.py +54 -0
  220. pas/plugins/identity/setuphandlers/__init__.py +188 -0
  221. pas/plugins/identity/setuphandlers/catalog.py +105 -0
  222. pas/plugins/identity/setuphandlers/plugins.py +187 -0
  223. pas/plugins/identity/sql/__init__.py +197 -0
  224. pas/plugins/identity/sql/configure.zcml +13 -0
  225. pas/plugins/identity/sql/engine.py +145 -0
  226. pas/plugins/identity/sql/models.py +97 -0
  227. pas/plugins/identity/testing.py +78 -0
  228. pas/plugins/identity/upgrades/__init__.py +0 -0
  229. pas/plugins/identity/upgrades/configure.zcml +21 -0
  230. pas_plugins_identity-1.0.0a1.dist-info/METADATA +203 -0
  231. pas_plugins_identity-1.0.0a1.dist-info/RECORD +235 -0
  232. pas_plugins_identity-1.0.0a1.dist-info/WHEEL +4 -0
  233. pas_plugins_identity-1.0.0a1.dist-info/entry_points.txt +6 -0
  234. pas_plugins_identity-1.0.0a1.dist-info/licenses/LICENSE.GPL +339 -0
  235. pas_plugins_identity-1.0.0a1.dist-info/licenses/LICENSE.md +15 -0
@@ -0,0 +1,14 @@
1
+ """Init and utils."""
2
+
3
+ from zope.i18nmessageid import MessageFactory
4
+
5
+ import logging
6
+
7
+
8
+ __version__ = "1.0.0a1"
9
+
10
+ PACKAGE_NAME = "pas.plugins.identity"
11
+
12
+ _ = MessageFactory(PACKAGE_NAME)
13
+
14
+ logger = logging.getLogger(PACKAGE_NAME)
@@ -0,0 +1,43 @@
1
+ <configure
2
+ xmlns="http://namespaces.zope.org/zope"
3
+ xmlns:i18n="http://namespaces.zope.org/i18n"
4
+ xmlns:zcml="http://namespaces.zope.org/zcml"
5
+ i18n_domain="pas.plugins.identity"
6
+ >
7
+
8
+ <i18n:registerTranslations directory="locales" />
9
+
10
+ <include
11
+ package="Products.CMFCore"
12
+ file="permissions.zcml"
13
+ />
14
+
15
+ <include file="dependencies.zcml" />
16
+ <include file="profiles.zcml" />
17
+ <include file="permissions.zcml" />
18
+
19
+ <include package=".core" />
20
+
21
+ <!-- The one optional layer. Its ZCML loads whenever its dependency is
22
+ importable; what switches it on in a site is applying its GenericSetup
23
+ profile, which is what installs the control panel and the browser layer
24
+ its registrations are bound to.
25
+
26
+ The ``[server]`` extra is genuinely optional: cryptography is a
27
+ compiled dependency, and a site that is not an authorization server has
28
+ no reason to carry it. -->
29
+ <include
30
+ package=".server"
31
+ zcml:condition="installed cryptography"
32
+ />
33
+
34
+ <!-- The second optional layer, loaded on the same terms: its ZCML is read
35
+ whenever SQLAlchemy is importable, and a site starts using it by naming
36
+ the sink in audit_sinks. No profile of its own, because it adds no
37
+ control panel and no browser layer, only one utility and one table. -->
38
+ <include
39
+ package=".sql"
40
+ zcml:condition="installed sqlalchemy"
41
+ />
42
+
43
+ </configure>
@@ -0,0 +1,44 @@
1
+ """Core layer: always installed, never imports from ``profile`` or ``server``.
2
+
3
+ The public surface of this package is re-exported here so that the optional
4
+ layers -- and third-party integrators -- have a single, stable import path.
5
+ See the import-linter contract in ``pyproject.toml``.
6
+ """
7
+
8
+ from pas.plugins.identity.core.events import EmailVerified
9
+ from pas.plugins.identity.core.events import ExternalIdentityAuthenticated
10
+ from pas.plugins.identity.core.events import IdentityLinked
11
+ from pas.plugins.identity.core.events import IdentityUnlinked
12
+ from pas.plugins.identity.core.events import SessionsRevoked
13
+ from pas.plugins.identity.core.events import UserClaimsRefreshed
14
+ from pas.plugins.identity.core.interfaces import IAuditSink
15
+ from pas.plugins.identity.core.interfaces import IAuditSource
16
+ from pas.plugins.identity.core.interfaces import IDriver
17
+ from pas.plugins.identity.core.interfaces import IIdentityStore
18
+ from pas.plugins.identity.core.patches import apply_patches
19
+ from pas.plugins.identity.core.store import IdentityRecord
20
+ from pas.plugins.identity.core.vocabularies import protect_vocabularies
21
+
22
+
23
+ # PlonePAS's group tool cannot survive a group that only one of its
24
+ # managers knows about, and this package is the second manager. Applied
25
+ # here because this module is imported by the ZCML that loads the layer.
26
+ apply_patches()
27
+
28
+ # Served under zope2.View unless something says otherwise, and one of them
29
+ # lists every group on the site.
30
+ protect_vocabularies()
31
+
32
+ __all__ = [
33
+ "EmailVerified",
34
+ "ExternalIdentityAuthenticated",
35
+ "IAuditSink",
36
+ "IAuditSource",
37
+ "IDriver",
38
+ "IIdentityStore",
39
+ "IdentityLinked",
40
+ "IdentityRecord",
41
+ "IdentityUnlinked",
42
+ "SessionsRevoked",
43
+ "UserClaimsRefreshed",
44
+ ]
@@ -0,0 +1,388 @@
1
+ """The audit log.
2
+
3
+ An authentication-event log, and deliberately not a session ledger: it records
4
+ that a login was attempted and how it ended, not what the user did afterwards.
5
+ Worth knowing before anyone reaches for it as an activity trail.
6
+
7
+ Storage is a bounded per-user ``OOBTree`` inside the PAS plugin, purged on
8
+ write against two registry-configured limits -- how many entries to keep per
9
+ user and how long to keep them. Purging on write rather than on a schedule
10
+ means the bound holds without anything having to run.
11
+
12
+ Privacy: the IP address and user agent are personal data, and are stored
13
+ only when ``pas.plugins.identity.audit_record_pii`` is switched on. It is off
14
+ by default. Credentials, tokens and authorization codes are never recorded, in
15
+ any configuration.
16
+ """
17
+
18
+ from BTrees.OOBTree import OOBTree
19
+ from datetime import datetime
20
+ from datetime import timedelta
21
+ from datetime import UTC
22
+ from pas.plugins.identity import logger
23
+ from pas.plugins.identity.core.interfaces import IAuditSink
24
+ from pas.plugins.identity.core.interfaces import IAuditSource
25
+ from pas.plugins.identity.core.interfaces import JSONDict
26
+ from persistent import Persistent
27
+ from persistent.list import PersistentList
28
+ from persistent.mapping import PersistentMapping
29
+ from plone import api
30
+ from zope.interface import implementer
31
+ from ZPublisher.HTTPRequest import HTTPRequest
32
+
33
+
34
+ #: Registry key bounding how many entries are kept per user.
35
+ MAX_ENTRIES_RECORD = "pas.plugins.identity.audit_max_entries"
36
+
37
+ #: Registry key bounding how long entries are kept.
38
+ MAX_DAYS_RECORD = "pas.plugins.identity.audit_max_days"
39
+
40
+ #: Registry key opting in to storing IP address and user agent.
41
+ RECORD_PII_RECORD = "pas.plugins.identity.audit_record_pii"
42
+
43
+ #: Registry key naming which sinks a site records to.
44
+ SINKS_RECORD = "pas.plugins.identity.audit_sinks"
45
+
46
+ #: Name the built-in ZODB sink is registered under, and the only one a
47
+ #: site records to unless it says otherwise. Named rather than anonymous
48
+ #: so that adding a second destination adds to the list instead of
49
+ #: replacing the log the control panel reads.
50
+ DEFAULT_SINK = "plugin"
51
+
52
+ #: Bucket for entries that cannot be attributed to a userid. A callback with
53
+ #: an unknown state has, by construction, no user to attribute it to, and
54
+ #: those are exactly the entries an operator investigating an attack wants.
55
+ UNATTRIBUTED = "\x00unattributed"
56
+
57
+ #: Event names this package records. Free-form strings would drift; a
58
+ #: consumer needs to be able to filter on something stable.
59
+ AUTHENTICATED = "authenticated"
60
+ IDENTITY_LINKED = "identity-linked"
61
+ IDENTITY_UNLINKED = "identity-unlinked"
62
+ EMAIL_VERIFIED = "email-verified"
63
+ CLAIMS_REFRESHED = "claims-refreshed"
64
+ FLOW_REFUSED = "flow-refused"
65
+ PAYLOAD_REJECTED = "payload-rejected"
66
+ LINK_REFUSED = "link-refused"
67
+ LINK_COLLISION = "link-collision"
68
+ MAGIC_LINK_SENT = "magic-link-sent"
69
+ MAGIC_LINK_CONFIRMED = "magic-link-confirmed"
70
+ MAGIC_LINK_REFUSED = "magic-link-refused"
71
+
72
+ #: A sign-in the provider authenticated and this site's policy refused:
73
+ #: a person outside every allowed group, or a new account at a provider
74
+ #: not allowed to create one. Distinct from ``flow-refused``, which
75
+ #: means the credential itself did not check out -- an operator looking
76
+ #: at a run of these is looking at their own configuration, not at an
77
+ #: attack.
78
+ SIGNIN_REFUSED = "signin-refused"
79
+
80
+
81
+ class AuditEntry(Persistent):
82
+ """One recorded authentication event.
83
+
84
+ :ivar event: Event name, one of this module's constants.
85
+ :ivar provider: Provider id the event concerns.
86
+ :ivar success: Whether the attempt succeeded.
87
+ :ivar timestamp: When it happened, in UTC.
88
+ :ivar detail: Extra non-credential context.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ event: str,
94
+ provider: str,
95
+ success: bool,
96
+ detail: JSONDict | None = None,
97
+ ) -> None:
98
+ """Record one event.
99
+
100
+ :param event: Event name.
101
+ :param provider: Provider id.
102
+ :param success: Whether the attempt succeeded.
103
+ :param detail: Extra non-credential context.
104
+ """
105
+ self.event = event
106
+ self.provider = provider
107
+ self.success = success
108
+ self.timestamp = datetime.now(UTC)
109
+ self.detail = PersistentMapping(detail or {})
110
+
111
+ def serialize(self) -> JSONDict:
112
+ """Render the entry for an API response.
113
+
114
+ :returns: JSON-ready mapping.
115
+ """
116
+ return {
117
+ "event": self.event,
118
+ "provider": self.provider,
119
+ "success": self.success,
120
+ "timestamp": self.timestamp.isoformat(),
121
+ "detail": dict(self.detail),
122
+ }
123
+
124
+ def __repr__(self) -> str:
125
+ """Return a debugging representation.
126
+
127
+ :returns: Event name and provider.
128
+ """
129
+ return f"<AuditEntry {self.event} {self.provider} success={self.success}>"
130
+
131
+
132
+ class AuditLog(Persistent):
133
+ """Bounded per-user store of :class:`AuditEntry` objects."""
134
+
135
+ def __init__(self) -> None:
136
+ """Create an empty log."""
137
+ self._by_userid: OOBTree = OOBTree()
138
+
139
+ def record(
140
+ self,
141
+ userid: str | None,
142
+ event: str,
143
+ provider: str,
144
+ success: bool,
145
+ detail: JSONDict | None = None,
146
+ ) -> AuditEntry:
147
+ """Append an entry and purge whatever now falls outside the bounds.
148
+
149
+ :param userid: Userid the event concerns; ``None`` when unresolved.
150
+ :param event: Event name.
151
+ :param provider: Provider id.
152
+ :param success: Whether the attempt succeeded.
153
+ :param detail: Extra non-credential context.
154
+ :returns: The stored entry.
155
+ """
156
+ entry = AuditEntry(event, provider, success, detail)
157
+ key = userid or UNATTRIBUTED
158
+ entries = self._by_userid.get(key)
159
+ if entries is None:
160
+ entries = self._by_userid[key] = PersistentList()
161
+ entries.append(entry)
162
+ self._purge(entries)
163
+ return entry
164
+
165
+ def entries(self, userid: str | None = None) -> list[AuditEntry]:
166
+ """Return recorded entries, newest first.
167
+
168
+ :param userid: Restrict to one user; ``None`` returns everything,
169
+ including the unattributed bucket.
170
+ :returns: Matching entries.
171
+ """
172
+ if userid is not None:
173
+ found = list(self._by_userid.get(userid, ()))
174
+ else:
175
+ found = [entry for bucket in self._by_userid.values() for entry in bucket]
176
+ return sorted(found, key=lambda entry: entry.timestamp, reverse=True)
177
+
178
+ def _purge(self, entries: PersistentList) -> None:
179
+ """Trim one user's entries back inside the configured bounds.
180
+
181
+ :param entries: The list to trim, oldest first.
182
+ """
183
+ max_days = _setting(MAX_DAYS_RECORD, 180)
184
+ if max_days > 0:
185
+ cutoff = datetime.now(UTC) - timedelta(days=max_days)
186
+ while entries and entries[0].timestamp < cutoff:
187
+ del entries[0]
188
+
189
+ max_entries = _setting(MAX_ENTRIES_RECORD, 500)
190
+ if max_entries > 0:
191
+ excess = len(entries) - max_entries
192
+ if excess > 0:
193
+ del entries[:excess]
194
+
195
+
196
+ def _setting(record: str, default: int) -> int:
197
+ """Read an integer registry setting.
198
+
199
+ :param record: Registry key.
200
+ :param default: Value to use when the record is missing or empty.
201
+ :returns: The configured value.
202
+ """
203
+ value = api.portal.get_registry_record(record, default=default)
204
+ return default if value is None else value
205
+
206
+
207
+ def record_pii() -> bool:
208
+ """Report whether IP address and user agent may be stored.
209
+
210
+ :returns: Whether the opt-in flag is on.
211
+ """
212
+ return bool(api.portal.get_registry_record(RECORD_PII_RECORD, default=False))
213
+
214
+
215
+ def request_detail(request: HTTPRequest | None) -> JSONDict:
216
+ """Return the request context an entry may carry.
217
+
218
+ Empty unless the privacy flag is on: an IP address identifies a person's
219
+ machine, and the default for this package is not to keep one.
220
+
221
+ :param request: The current request, or ``None``.
222
+ :returns: Mapping with ``ip`` and ``user_agent``, or an empty mapping.
223
+ """
224
+ if request is None or not record_pii():
225
+ return {}
226
+ return {
227
+ "ip": request.get("HTTP_X_FORWARDED_FOR") or request.get("REMOTE_ADDR", ""),
228
+ "user_agent": request.get("HTTP_USER_AGENT", ""),
229
+ }
230
+
231
+
232
+ @implementer(IAuditSink)
233
+ @implementer(IAuditSink, IAuditSource)
234
+ class PluginAuditSink:
235
+ """The built-in sink: writes into the identity plugin's own log.
236
+
237
+ Registered under :data:`DEFAULT_SINK`, and the one destination a site
238
+ records to until its ``audit_sinks`` setting names others. Resolves the
239
+ plugin on every call rather than holding one, so it writes to whichever
240
+ Plone site is being served.
241
+
242
+ Provides :class:`IAuditSource` as well as :class:`IAuditSink`, which is
243
+ what makes it the log the control panel and ``@audit-log`` read: a
244
+ deployment can add destinations without giving up the one thing on the
245
+ site that can answer a question about them.
246
+ """
247
+
248
+ def record(
249
+ self,
250
+ userid: str | None,
251
+ event: str,
252
+ provider: str,
253
+ success: bool,
254
+ detail: JSONDict | None = None,
255
+ ) -> None:
256
+ """Append one authentication event.
257
+
258
+ :param userid: Userid the event concerns, ``None`` when unresolved.
259
+ :param event: Event name.
260
+ :param provider: Provider id involved.
261
+ :param success: Whether the attempt succeeded.
262
+ :param detail: Extra, non-credential context. Never tokens.
263
+ """
264
+ log = _log()
265
+ if log is None:
266
+ return
267
+ log.record(userid, event, provider, success, detail)
268
+
269
+ def entries(self, userid: str | None = None) -> list[AuditEntry]:
270
+ """Return recorded entries, newest first.
271
+
272
+ :param userid: Restrict to one user; ``None`` returns site-wide.
273
+ :returns: Matching entries.
274
+ """
275
+ log = _log()
276
+ return [] if log is None else log.entries(userid)
277
+
278
+
279
+ def _log() -> AuditLog | None:
280
+ """Return the current site's audit log.
281
+
282
+ :returns: The log, or ``None`` when the add-on is not installed here --
283
+ auditing must never be the reason a request fails.
284
+ """
285
+ from pas.plugins.identity.core.pas import PLUGIN_ID
286
+
287
+ try:
288
+ plugin = api.portal.get_tool("acl_users")[PLUGIN_ID]
289
+ except (KeyError, AttributeError, api.exc.CannotGetPortalError):
290
+ logger.info("No identity plugin here; dropping an audit entry")
291
+ return None
292
+ return plugin.audit
293
+
294
+
295
+ def sink_names() -> tuple[str, ...]:
296
+ """Return the sinks this site records to, in the order it named them.
297
+
298
+ :returns: Utility names. Falls back to :data:`DEFAULT_SINK` alone when
299
+ the record is missing or empty, so a site that has never touched the
300
+ setting behaves exactly as it did before there was one.
301
+ """
302
+ configured = api.portal.get_registry_record(SINKS_RECORD, default=None)
303
+ if not configured:
304
+ return (DEFAULT_SINK,)
305
+ return tuple(configured)
306
+
307
+
308
+ def source() -> IAuditSource | None:
309
+ """Return the first configured sink that can be read back.
310
+
311
+ Order matters and is the operator's: the sinks are tried as listed, so a
312
+ site fanning out to both a database and the built-in log decides which of
313
+ them answers the control panel by which it names first.
314
+
315
+ :returns: The source, or ``None`` when nothing configured can answer --
316
+ which is a configuration answer rather than an error, and is reported
317
+ as such rather than as an empty log.
318
+ """
319
+ from zope.component import queryUtility
320
+
321
+ for name in sink_names():
322
+ sink = queryUtility(IAuditSink, name=name, default=None)
323
+ if IAuditSource.providedBy(sink):
324
+ return sink
325
+ return None
326
+
327
+
328
+ def entries(userid: str | None = None) -> list:
329
+ """Return recorded entries, newest first.
330
+
331
+ :param userid: Restrict to one user; ``None`` returns site-wide.
332
+ :returns: The entries, empty when nothing configured can be read back.
333
+ A caller that has to tell "no readable sink" from "nothing recorded"
334
+ asks :func:`source` instead.
335
+ """
336
+ reader = source()
337
+ return [] if reader is None else reader.entries(userid)
338
+
339
+
340
+ def record(
341
+ userid: str | None,
342
+ event: str,
343
+ provider: str,
344
+ success: bool,
345
+ detail: JSONDict | None = None,
346
+ request: HTTPRequest | None = None,
347
+ ) -> None:
348
+ """Record one event to every configured sink.
349
+
350
+ The single entry point every caller in this package uses, so that adding
351
+ a destination adds it everywhere. Each sink is written independently and
352
+ a failure in one is logged and swallowed: refusing a login because an
353
+ audit destination is unwritable would turn a bookkeeping problem into an
354
+ outage, and a database on the far side of a network is a much better
355
+ reason to hold that line than the ZODB ever was.
356
+
357
+ A configured name that resolves to no utility is logged too. It is almost
358
+ always a sink whose extra is not installed, and silence there would leave
359
+ a site recording to fewer places than its operator believes.
360
+
361
+ :param userid: Userid the event concerns, ``None`` when unresolved.
362
+ :param event: Event name.
363
+ :param provider: Provider id involved.
364
+ :param success: Whether the attempt succeeded.
365
+ :param detail: Extra, non-credential context.
366
+ :param request: Current request, for the opt-in PII fields.
367
+ """
368
+ from zope.component import queryUtility
369
+
370
+ payload = {**(detail or {}), **request_detail(request)}
371
+ for name in sink_names():
372
+ sink = queryUtility(IAuditSink, name=name, default=None)
373
+ if sink is None:
374
+ logger.warning(
375
+ "No audit sink registered under %r; dropping a %r entry. "
376
+ "Is the extra that provides it installed?",
377
+ name,
378
+ event,
379
+ )
380
+ continue
381
+ try:
382
+ sink.record(userid, event, provider, success, payload)
383
+ except Exception:
384
+ logger.exception(
385
+ "Could not write an audit entry for %r to the %r sink",
386
+ event,
387
+ name,
388
+ )
@@ -0,0 +1,36 @@
1
+ <configure
2
+ xmlns="http://namespaces.zope.org/zope"
3
+ i18n_domain="pas.plugins.identity"
4
+ >
5
+
6
+ <!-- The built-in sink writes into the identity plugin's own bounded log,
7
+ and is the only one a site records to until its audit_sinks setting
8
+ names others. Registered under a name rather than anonymously: a
9
+ deployment adding a database or a syslog feed should be adding a
10
+ destination, not silently replacing the one the control panel reads. -->
11
+ <utility
12
+ factory=".PluginAuditSink"
13
+ provides="pas.plugins.identity.core.interfaces.IAuditSink"
14
+ name="plugin"
15
+ />
16
+
17
+ <!-- Write-only, and registered whether or not anything uses it: a site
18
+ starts recording here by naming it in audit_sinks, and a sink that is
19
+ not registered cannot be named. Provides no IAuditSource, so a site
20
+ recording only here is told its records are elsewhere rather than
21
+ shown an empty log. -->
22
+ <utility
23
+ factory=".logsink.LoggingAuditSink"
24
+ provides="pas.plugins.identity.core.interfaces.IAuditSink"
25
+ name="log"
26
+ />
27
+
28
+ <!-- Successes are recorded from the event contract, so anything that fires
29
+ an event is audited, whoever fired it. -->
30
+ <subscriber handler=".subscribers.record_authentication" />
31
+ <subscriber handler=".subscribers.record_link" />
32
+ <subscriber handler=".subscribers.record_unlink" />
33
+ <subscriber handler=".subscribers.record_email_verified" />
34
+ <subscriber handler=".subscribers.record_claims_refresh" />
35
+
36
+ </configure>
@@ -0,0 +1,98 @@
1
+ """An audit sink that writes to the Python log and nothing else.
2
+
3
+ The write-only case, and the reason
4
+ :class:`~pas.plugins.identity.core.interfaces.IAuditSource` is a separate
5
+ interface: a log file is somewhere records *go*, not somewhere a Plone site
6
+ can query. This sink provides
7
+ :class:`~pas.plugins.identity.core.interfaces.IAuditSink` alone, and a site
8
+ recording only here is told by ``@audit-log`` that its records live somewhere
9
+ this site cannot read, rather than shown an empty list.
10
+
11
+ Why it is worth having at all, when the built-in sink already keeps records
12
+ in the ZODB:
13
+
14
+ * **It leaves the database alone.** A ZODB write per authentication event is
15
+ a write per login attempt, including the failed ones, which is exactly the
16
+ traffic a site under credential-stuffing gets most of. A log line costs no
17
+ transaction and cannot conflict.
18
+ * **It is the shape every other tool already reads.** Shipping records to
19
+ journald, a sidecar, a log collector or a SIEM means writing lines that
20
+ something else tails. That is a solved problem for logs and a bespoke
21
+ integration for anything else.
22
+ * **It survives the site.** Records written here outlive an instance, a
23
+ ``Data.fs`` restored from backup, and the retention bounds that purge the
24
+ built-in log on every write.
25
+
26
+ Records go to a logger of this module's own rather than to the package logger,
27
+ so a deployment can route authentication events to their own handler, file or
28
+ level without moving everything else the add-on says. That separation is the
29
+ main reason to choose this sink, and it is why the logger is named rather than
30
+ inherited.
31
+
32
+ Privacy: this sink writes what it is handed, which is what
33
+ :func:`~pas.plugins.identity.core.audit.record` already assembled. The IP
34
+ address and user agent reach it only when ``audit_record_pii`` is on, exactly
35
+ as they reach the built-in sink. Worth remembering that a log file is a
36
+ different disclosure surface from the ZODB, though: it is usually readable by
37
+ more people, shipped off the host, and kept by a retention policy this package
38
+ does not control.
39
+ """
40
+
41
+ from pas.plugins.identity.core.interfaces import IAuditSink
42
+ from pas.plugins.identity.core.interfaces import JSONDict
43
+ from zope.interface import implementer
44
+
45
+ import logging
46
+
47
+
48
+ #: Where this sink writes. Named rather than inherited from the package
49
+ #: logger so that authentication events can be routed on their own.
50
+ logger = logging.getLogger("pas.plugins.identity.audit")
51
+
52
+ #: Name this sink is registered under, and what a site puts in
53
+ #: ``audit_sinks`` to start recording here.
54
+ SINK_NAME = "log"
55
+
56
+
57
+ @implementer(IAuditSink)
58
+ class LoggingAuditSink:
59
+ """Write each authentication event to the log, and answer no questions.
60
+
61
+ Deliberately provides no ``entries``. A sink that offered one returning
62
+ an empty list would be indistinguishable, to every caller, from a site
63
+ where nothing had happened.
64
+ """
65
+
66
+ def record(
67
+ self,
68
+ userid: str | None,
69
+ event: str,
70
+ provider: str,
71
+ success: bool,
72
+ detail: JSONDict | None = None,
73
+ ) -> None:
74
+ """Write one authentication event as a single log line.
75
+
76
+ One line rather than several, because a multi-line record is a
77
+ multi-line record to grep, and these are read by grepping.
78
+
79
+ The level follows the outcome: a refusal is a warning, since a run of
80
+ them is what an operator wants to notice, and a success is info. This
81
+ is the one decision the sink makes about what it is handed; everything
82
+ else is written through.
83
+
84
+ :param userid: Userid the event concerns, ``None`` when unresolved.
85
+ :param event: Event name.
86
+ :param provider: Provider id involved.
87
+ :param success: Whether the attempt succeeded.
88
+ :param detail: Extra, non-credential context. Never tokens.
89
+ """
90
+ logger.log(
91
+ logging.INFO if success else logging.WARNING,
92
+ "audit event=%s provider=%s userid=%s success=%s detail=%r",
93
+ event,
94
+ provider,
95
+ userid if userid is not None else "-",
96
+ success,
97
+ detail or {},
98
+ )