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.
- pas/plugins/identity/__init__.py +14 -0
- pas/plugins/identity/configure.zcml +43 -0
- pas/plugins/identity/core/__init__.py +44 -0
- pas/plugins/identity/core/audit/__init__.py +388 -0
- pas/plugins/identity/core/audit/configure.zcml +36 -0
- pas/plugins/identity/core/audit/logsink.py +98 -0
- pas/plugins/identity/core/audit/subscribers.py +107 -0
- pas/plugins/identity/core/behaviors/__init__.py +11 -0
- pas/plugins/identity/core/behaviors/configure.zcml +40 -0
- pas/plugins/identity/core/behaviors/membership.py +100 -0
- pas/plugins/identity/core/behaviors/password.py +146 -0
- pas/plugins/identity/core/browser/__init__.py +1 -0
- pas/plugins/identity/core/browser/configure.zcml +18 -0
- pas/plugins/identity/core/browser/logout.py +107 -0
- pas/plugins/identity/core/catalog.py +278 -0
- pas/plugins/identity/core/completeness.py +234 -0
- pas/plugins/identity/core/configure.zcml +24 -0
- pas/plugins/identity/core/container.py +370 -0
- pas/plugins/identity/core/contents/__init__.py +13 -0
- pas/plugins/identity/core/contents/configure.zcml +22 -0
- pas/plugins/identity/core/contents/group.py +127 -0
- pas/plugins/identity/core/contents/profile.py +312 -0
- pas/plugins/identity/core/controlpanel/__init__.py +931 -0
- pas/plugins/identity/core/controlpanel/configure.zcml +25 -0
- pas/plugins/identity/core/controlpanel/controlpanel.py +39 -0
- pas/plugins/identity/core/controlpanel/interfaces.py +518 -0
- pas/plugins/identity/core/controlpanel/view.py +33 -0
- pas/plugins/identity/core/doctor.py +241 -0
- pas/plugins/identity/core/drivers/__init__.py +33 -0
- pas/plugins/identity/core/drivers/base.py +224 -0
- pas/plugins/identity/core/drivers/configure.zcml +37 -0
- pas/plugins/identity/core/drivers/emaillink.py +67 -0
- pas/plugins/identity/core/drivers/github.py +172 -0
- pas/plugins/identity/core/drivers/google.py +21 -0
- pas/plugins/identity/core/drivers/identity.py +69 -0
- pas/plugins/identity/core/drivers/oidc.py +20 -0
- pas/plugins/identity/core/drivers/settings.py +375 -0
- pas/plugins/identity/core/events/__init__.py +197 -0
- pas/plugins/identity/core/flows/__init__.py +571 -0
- pas/plugins/identity/core/flows/magiclink.py +243 -0
- pas/plugins/identity/core/flows/metadata.py +229 -0
- pas/plugins/identity/core/flows/session.py +205 -0
- pas/plugins/identity/core/indexers/__init__.py +108 -0
- pas/plugins/identity/core/indexers/configure.zcml +50 -0
- pas/plugins/identity/core/interfaces.py +481 -0
- pas/plugins/identity/core/localroles.py +74 -0
- pas/plugins/identity/core/logout.py +247 -0
- pas/plugins/identity/core/pas/__init__.py +16 -0
- pas/plugins/identity/core/pas/configure.zcml +13 -0
- pas/plugins/identity/core/pas/plugin.py +1400 -0
- pas/plugins/identity/core/pas/profile.py +780 -0
- pas/plugins/identity/core/patches.py +81 -0
- pas/plugins/identity/core/portraits.py +337 -0
- pas/plugins/identity/core/serializers/__init__.py +5 -0
- pas/plugins/identity/core/serializers/configure.zcml +12 -0
- pas/plugins/identity/core/serializers/user.py +124 -0
- pas/plugins/identity/core/services/__init__.py +19 -0
- pas/plugins/identity/core/services/auditlog/__init__.py +16 -0
- pas/plugins/identity/core/services/auditlog/configure.zcml +18 -0
- pas/plugins/identity/core/services/auditlog/get.py +84 -0
- pas/plugins/identity/core/services/base.py +37 -0
- pas/plugins/identity/core/services/callback/__init__.py +1 -0
- pas/plugins/identity/core/services/callback/configure.zcml +19 -0
- pas/plugins/identity/core/services/callback/post.py +355 -0
- pas/plugins/identity/core/services/configure.zcml +67 -0
- pas/plugins/identity/core/services/groups/__init__.py +126 -0
- pas/plugins/identity/core/services/groups/configure.zcml +20 -0
- pas/plugins/identity/core/services/groups/get.py +125 -0
- pas/plugins/identity/core/services/identities/__init__.py +85 -0
- pas/plugins/identity/core/services/identities/configure.zcml +36 -0
- pas/plugins/identity/core/services/identities/delete.py +39 -0
- pas/plugins/identity/core/services/identities/get.py +84 -0
- pas/plugins/identity/core/services/identities/post.py +178 -0
- pas/plugins/identity/core/services/jwt.py +48 -0
- pas/plugins/identity/core/services/login/__init__.py +50 -0
- pas/plugins/identity/core/services/login/configure.zcml +25 -0
- pas/plugins/identity/core/services/login/expander.py +47 -0
- pas/plugins/identity/core/services/login/get.py +128 -0
- pas/plugins/identity/core/services/magiclink/__init__.py +151 -0
- pas/plugins/identity/core/services/magiclink/configure.zcml +28 -0
- pas/plugins/identity/core/services/magiclink/confirm.py +200 -0
- pas/plugins/identity/core/services/magiclink/post.py +69 -0
- pas/plugins/identity/core/services/myprofile.py +114 -0
- pas/plugins/identity/core/services/providers/__init__.py +104 -0
- pas/plugins/identity/core/services/providers/configure.zcml +55 -0
- pas/plugins/identity/core/services/providers/delete.py +35 -0
- pas/plugins/identity/core/services/providers/drivers.py +51 -0
- pas/plugins/identity/core/services/providers/get.py +38 -0
- pas/plugins/identity/core/services/providers/patch.py +97 -0
- pas/plugins/identity/core/services/providers/post.py +121 -0
- pas/plugins/identity/core/services/schema.py +44 -0
- pas/plugins/identity/core/services/types.py +105 -0
- pas/plugins/identity/core/services/useraccount/__init__.py +134 -0
- pas/plugins/identity/core/services/useraccount/configure.zcml +19 -0
- pas/plugins/identity/core/services/useraccount/get.py +139 -0
- pas/plugins/identity/core/services/users.py +218 -0
- pas/plugins/identity/core/store/__init__.py +244 -0
- pas/plugins/identity/core/subscribers/__init__.py +597 -0
- pas/plugins/identity/core/subscribers/configure.zcml +102 -0
- pas/plugins/identity/core/subscribers/gate.py +303 -0
- pas/plugins/identity/core/subscribers/principals.py +205 -0
- pas/plugins/identity/core/txn.py +150 -0
- pas/plugins/identity/core/utils/__init__.py +11 -0
- pas/plugins/identity/core/utils/emails.py +126 -0
- pas/plugins/identity/core/utils/flags.py +68 -0
- pas/plugins/identity/core/utils/groupmap.py +77 -0
- pas/plugins/identity/core/utils/nesting.py +125 -0
- pas/plugins/identity/core/utils/propertymap.py +66 -0
- pas/plugins/identity/core/utils/svg.py +305 -0
- pas/plugins/identity/core/verification.py +169 -0
- pas/plugins/identity/core/versioning.py +176 -0
- pas/plugins/identity/core/vocabularies/__init__.py +45 -0
- pas/plugins/identity/core/vocabularies/auditsinks.py +54 -0
- pas/plugins/identity/core/vocabularies/configure.zcml +21 -0
- pas/plugins/identity/core/vocabularies/groups.py +52 -0
- pas/plugins/identity/core/vocabularies/userfields.py +49 -0
- pas/plugins/identity/dependencies.zcml +16 -0
- pas/plugins/identity/exportimport/__init__.py +56 -0
- pas/plugins/identity/exportimport/authomatic.py +273 -0
- pas/plugins/identity/exportimport/cli.py +183 -0
- pas/plugins/identity/exportimport/exporter.py +168 -0
- pas/plugins/identity/exportimport/importer.py +445 -0
- pas/plugins/identity/exportimport/schema.py +254 -0
- pas/plugins/identity/interfaces.py +7 -0
- pas/plugins/identity/locales/__init__.py +0 -0
- pas/plugins/identity/locales/__main__.py +69 -0
- pas/plugins/identity/locales/de/LC_MESSAGES/pas.plugins.identity.mo +0 -0
- pas/plugins/identity/locales/de/LC_MESSAGES/pas.plugins.identity.po +39 -0
- pas/plugins/identity/locales/en/LC_MESSAGES/pas.plugins.identity.mo +0 -0
- pas/plugins/identity/locales/en/LC_MESSAGES/pas.plugins.identity.po +39 -0
- pas/plugins/identity/locales/es/LC_MESSAGES/pas.plugins.identity.mo +0 -0
- pas/plugins/identity/locales/es/LC_MESSAGES/pas.plugins.identity.po +39 -0
- pas/plugins/identity/locales/it/LC_MESSAGES/pas.plugins.identity.mo +0 -0
- pas/plugins/identity/locales/it/LC_MESSAGES/pas.plugins.identity.po +39 -0
- pas/plugins/identity/locales/pas.plugins.identity.pot +42 -0
- pas/plugins/identity/locales/pt_BR/LC_MESSAGES/pas.plugins.identity.mo +0 -0
- pas/plugins/identity/locales/pt_BR/LC_MESSAGES/pas.plugins.identity.po +39 -0
- pas/plugins/identity/migration/__init__.py +103 -0
- pas/plugins/identity/migration/authomatic.py +240 -0
- pas/plugins/identity/migration/oidc.py +242 -0
- pas/plugins/identity/permissions.zcml +53 -0
- pas/plugins/identity/profiles/default/browserlayer.xml +6 -0
- pas/plugins/identity/profiles/default/controlpanel.xml +26 -0
- pas/plugins/identity/profiles/default/metadata.xml +8 -0
- pas/plugins/identity/profiles/default/registry/pas.plugins.identity.core.controlpanel.interfaces.IIdentitySettings.xml +32 -0
- pas/plugins/identity/profiles/default/registry/pas.plugins.identity.core.controlpanel.interfaces.IProfileSettings.xml +60 -0
- pas/plugins/identity/profiles/default/repositorytool.xml +26 -0
- pas/plugins/identity/profiles/default/rolemap.xml +71 -0
- pas/plugins/identity/profiles/default/toolset.xml +10 -0
- pas/plugins/identity/profiles/default/types/UserGroup.xml +60 -0
- pas/plugins/identity/profiles/default/types/UserProfile.xml +77 -0
- pas/plugins/identity/profiles/default/types.xml +11 -0
- pas/plugins/identity/profiles/default/workflows/user_group_workflow/definition.xml +182 -0
- pas/plugins/identity/profiles/default/workflows/user_profile_workflow/definition.xml +491 -0
- pas/plugins/identity/profiles/default/workflows.xml +19 -0
- pas/plugins/identity/profiles/rebuild-catalog/metadata.xml +7 -0
- pas/plugins/identity/profiles/uninstall/browserlayer.xml +6 -0
- pas/plugins/identity/profiles/uninstall/controlpanel.xml +11 -0
- pas/plugins/identity/profiles/uninstall/registry.xml +85 -0
- pas/plugins/identity/profiles/uninstall/toolset.xml +7 -0
- pas/plugins/identity/profiles/uninstall/types.xml +11 -0
- pas/plugins/identity/profiles/uninstall/workflows.xml +11 -0
- pas/plugins/identity/profiles.zcml +51 -0
- pas/plugins/identity/py.typed +0 -0
- pas/plugins/identity/server/__init__.py +10 -0
- pas/plugins/identity/server/browser/__init__.py +6 -0
- pas/plugins/identity/server/browser/authorize.py +471 -0
- pas/plugins/identity/server/browser/configure.zcml +68 -0
- pas/plugins/identity/server/browser/discovery.py +123 -0
- pas/plugins/identity/server/browser/templates/consent.pt +108 -0
- pas/plugins/identity/server/browser/token.py +375 -0
- pas/plugins/identity/server/browser/userinfo.py +94 -0
- pas/plugins/identity/server/claims.py +237 -0
- pas/plugins/identity/server/configure.zcml +27 -0
- pas/plugins/identity/server/consent/__init__.py +146 -0
- pas/plugins/identity/server/consent/screen.py +41 -0
- pas/plugins/identity/server/controlpanel/__init__.py +13 -0
- pas/plugins/identity/server/controlpanel/clients.py +560 -0
- pas/plugins/identity/server/controlpanel/configure.zcml +16 -0
- pas/plugins/identity/server/controlpanel/interfaces.py +449 -0
- pas/plugins/identity/server/controlpanel/panel.py +47 -0
- pas/plugins/identity/server/discovery.py +116 -0
- pas/plugins/identity/server/grants/__init__.py +8 -0
- pas/plugins/identity/server/grants/codes.py +284 -0
- pas/plugins/identity/server/grants/refresh.py +272 -0
- pas/plugins/identity/server/grants/tokens.py +276 -0
- pas/plugins/identity/server/interfaces.py +136 -0
- pas/plugins/identity/server/pas.py +206 -0
- pas/plugins/identity/server/profiles/default/browserlayer.xml +6 -0
- pas/plugins/identity/server/profiles/default/controlpanel.xml +30 -0
- pas/plugins/identity/server/profiles/default/metadata.xml +7 -0
- pas/plugins/identity/server/profiles/default/registry/pas.plugins.identity.server.interfaces.IServerSettings.xml +31 -0
- pas/plugins/identity/server/profiles/uninstall/browserlayer.xml +7 -0
- pas/plugins/identity/server/profiles/uninstall/controlpanel.xml +9 -0
- pas/plugins/identity/server/profiles/uninstall/metadata.xml +4 -0
- pas/plugins/identity/server/profiles/uninstall/registry.xml +21 -0
- pas/plugins/identity/server/profiles.zcml +31 -0
- pas/plugins/identity/server/services/__init__.py +91 -0
- pas/plugins/identity/server/services/clients/__init__.py +35 -0
- pas/plugins/identity/server/services/clients/delete.py +35 -0
- pas/plugins/identity/server/services/clients/get.py +40 -0
- pas/plugins/identity/server/services/clients/patch.py +55 -0
- pas/plugins/identity/server/services/clients/post.py +92 -0
- pas/plugins/identity/server/services/configure.zcml +108 -0
- pas/plugins/identity/server/services/consent/__init__.py +20 -0
- pas/plugins/identity/server/services/consent/get.py +107 -0
- pas/plugins/identity/server/services/grants/__init__.py +22 -0
- pas/plugins/identity/server/services/grants/delete.py +96 -0
- pas/plugins/identity/server/services/grants/get.py +82 -0
- pas/plugins/identity/server/services/keys.py +97 -0
- pas/plugins/identity/server/setuphandlers.py +159 -0
- pas/plugins/identity/server/subscribers/__init__.py +45 -0
- pas/plugins/identity/server/subscribers/configure.zcml +11 -0
- pas/plugins/identity/server/utils/__init__.py +6 -0
- pas/plugins/identity/server/utils/keys.py +150 -0
- pas/plugins/identity/server/utils/session.py +155 -0
- pas/plugins/identity/server/vocabularies/__init__.py +10 -0
- pas/plugins/identity/server/vocabularies/configure.zcml +9 -0
- pas/plugins/identity/server/vocabularies/scopes.py +54 -0
- pas/plugins/identity/setuphandlers/__init__.py +188 -0
- pas/plugins/identity/setuphandlers/catalog.py +105 -0
- pas/plugins/identity/setuphandlers/plugins.py +187 -0
- pas/plugins/identity/sql/__init__.py +197 -0
- pas/plugins/identity/sql/configure.zcml +13 -0
- pas/plugins/identity/sql/engine.py +145 -0
- pas/plugins/identity/sql/models.py +97 -0
- pas/plugins/identity/testing.py +78 -0
- pas/plugins/identity/upgrades/__init__.py +0 -0
- pas/plugins/identity/upgrades/configure.zcml +21 -0
- pas_plugins_identity-1.0.0a1.dist-info/METADATA +203 -0
- pas_plugins_identity-1.0.0a1.dist-info/RECORD +235 -0
- pas_plugins_identity-1.0.0a1.dist-info/WHEEL +4 -0
- pas_plugins_identity-1.0.0a1.dist-info/entry_points.txt +6 -0
- pas_plugins_identity-1.0.0a1.dist-info/licenses/LICENSE.GPL +339 -0
- pas_plugins_identity-1.0.0a1.dist-info/licenses/LICENSE.md +15 -0
|
@@ -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
|
+
)
|