keepup-admin 0.1.0__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 (86) hide show
  1. keepup/THIRD-PARTY.md +44 -0
  2. keepup/__init__.py +41 -0
  3. keepup/api_versions.py +100 -0
  4. keepup/audit.py +499 -0
  5. keepup/auth/__init__.py +7 -0
  6. keepup/auth/config.py +128 -0
  7. keepup/auth/dependencies.py +485 -0
  8. keepup/auth/dto/__init__.py +0 -0
  9. keepup/auth/dto/token.py +31 -0
  10. keepup/auth/factory.py +28 -0
  11. keepup/auth/login_throttle.py +151 -0
  12. keepup/auth/oidc.py +245 -0
  13. keepup/auth/oidc_policy.py +111 -0
  14. keepup/auth/oidc_routes.py +288 -0
  15. keepup/auth/panel_session.py +220 -0
  16. keepup/auth/permissions.py +59 -0
  17. keepup/auth/providers/__init__.py +0 -0
  18. keepup/auth/providers/base.py +168 -0
  19. keepup/auth/providers/local.py +180 -0
  20. keepup/auth/routes.py +660 -0
  21. keepup/auth/seed_accounts.py +322 -0
  22. keepup/auth/session_lifetime.py +104 -0
  23. keepup/auth/signing_key.py +138 -0
  24. keepup/auth/websocket.py +86 -0
  25. keepup/cluster.py +634 -0
  26. keepup/db.py +663 -0
  27. keepup/events.py +764 -0
  28. keepup/factory.py +484 -0
  29. keepup/instance.py +46 -0
  30. keepup/integrations.py +260 -0
  31. keepup/locks.py +412 -0
  32. keepup/logging_setup.py +690 -0
  33. keepup/metrics.py +818 -0
  34. keepup/metrics_retention.py +376 -0
  35. keepup/modules.py +572 -0
  36. keepup/notification_bus.py +355 -0
  37. keepup/plugins/__init__.py +9 -0
  38. keepup/plugins/admin.py +246 -0
  39. keepup/plugins/base.py +234 -0
  40. keepup/plugins/enablement.py +184 -0
  41. keepup/plugins/registry.py +171 -0
  42. keepup/plugins/route_mask.py +338 -0
  43. keepup/plugins/routes.py +376 -0
  44. keepup/roles.py +17 -0
  45. keepup/scheduler.py +93 -0
  46. keepup/schema.py +295 -0
  47. keepup/sections.json +104 -0
  48. keepup/settings.py +184 -0
  49. keepup/static/css/aos.css +1 -0
  50. keepup/static/css/main_nebula.css +232 -0
  51. keepup/static/css/main_new.css +852 -0
  52. keepup/static/css/tailwind.css +1 -0
  53. keepup/static/index_nebula.html +293 -0
  54. keepup/static/index_new.html +286 -0
  55. keepup/static/js/aos.js +1 -0
  56. keepup/static/js/feather-icons.js +13 -0
  57. keepup/static/js/main_new.js +1861 -0
  58. keepup/static/js/tailwind.js +83 -0
  59. keepup/static/modules/css/background_tasks.css +233 -0
  60. keepup/static/modules/css/cluster.css +16 -0
  61. keepup/static/modules/css/event_manager.css +386 -0
  62. keepup/static/modules/css/integration_logs.css +33 -0
  63. keepup/static/modules/css/metrics.css +115 -0
  64. keepup/static/modules/css/modules.css +189 -0
  65. keepup/static/modules/css/themes.css +563 -0
  66. keepup/static/modules/css/users.css +278 -0
  67. keepup/static/modules/js/background_tasks.js +657 -0
  68. keepup/static/modules/js/chart.js +14 -0
  69. keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  70. keepup/static/modules/js/cluster.js +363 -0
  71. keepup/static/modules/js/event_manager.js +979 -0
  72. keepup/static/modules/js/integration_logs.js +767 -0
  73. keepup/static/modules/js/metrics.js +908 -0
  74. keepup/static/modules/js/modules.js +1086 -0
  75. keepup/static/modules/js/themes.js +653 -0
  76. keepup/static/modules/js/users.js +784 -0
  77. keepup/tables.py +302 -0
  78. keepup/themes.py +496 -0
  79. keepup/web.py +182 -0
  80. keepup_admin-0.1.0.dist-info/METADATA +117 -0
  81. keepup_admin-0.1.0.dist-info/RECORD +86 -0
  82. keepup_admin-0.1.0.dist-info/WHEEL +5 -0
  83. keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
  84. keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
  85. keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
  86. keepup_admin-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,151 @@
1
+ """Counting failed logins, so a password cannot be guessed at full speed.
2
+
3
+ The counter lives in the database rather than in the process. The application is
4
+ meant to run as several replicas against one database, and a counter held in
5
+ memory is one the next attempt walks around simply by landing on another replica.
6
+
7
+ Attempts are counted per username, not per address. An address behind a shared
8
+ uplink belongs to many people, and locking it out shuts the door on everyone who
9
+ happens to share it. The trade is that somebody can keep a known name locked for
10
+ the length of the window; that window is short, and the alternative is worse.
11
+ """
12
+
13
+ import logging
14
+ import os
15
+ from datetime import datetime, timedelta
16
+ from typing import Optional
17
+
18
+ from sqlalchemy import Column, DateTime, Integer, String, Text
19
+
20
+ from keepup import tables
21
+ from keepup.db import DatabaseManagerV2
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ #: How many failures in a row close the door, and for how long.
26
+ MAX_ATTEMPTS_ENV = "LOGIN_MAX_ATTEMPTS"
27
+ LOCKOUT_MINUTES_ENV = "LOGIN_LOCKOUT_MINUTES"
28
+ DEFAULT_MAX_ATTEMPTS = 10
29
+ DEFAULT_LOCKOUT_MINUTES = 15
30
+
31
+
32
+ def max_attempts(environ=None) -> int:
33
+ """Failures tolerated before the lockout window starts."""
34
+ environ = environ if environ is not None else os.environ
35
+ try:
36
+ value = int(environ.get(MAX_ATTEMPTS_ENV, DEFAULT_MAX_ATTEMPTS))
37
+ except (TypeError, ValueError):
38
+ return DEFAULT_MAX_ATTEMPTS
39
+ return value if value > 0 else DEFAULT_MAX_ATTEMPTS
40
+
41
+
42
+ def lockout_window(environ=None) -> timedelta:
43
+ """How long the door stays closed once the attempts are spent."""
44
+ environ = environ if environ is not None else os.environ
45
+ try:
46
+ minutes = int(environ.get(LOCKOUT_MINUTES_ENV, DEFAULT_LOCKOUT_MINUTES))
47
+ except (TypeError, ValueError):
48
+ minutes = DEFAULT_LOCKOUT_MINUTES
49
+ return timedelta(minutes=minutes if minutes > 0 else DEFAULT_LOCKOUT_MINUTES)
50
+
51
+
52
+ LOGIN_ATTEMPTS = tables.table(
53
+ "login_attempts",
54
+ Column("username", String(255).with_variant(Text(), "sqlite"), primary_key=True, nullable=True),
55
+ Column("failures", Integer, nullable=False, server_default=tables.sql_text("0")),
56
+ Column("last_failure_at", DateTime),
57
+ )
58
+
59
+
60
+ def init_login_attempts_table() -> None:
61
+ """Create the table this module keeps its count in.
62
+
63
+ There are no migrations: each start creates what is missing.
64
+ """
65
+ tables.ensure_tables(LOGIN_ATTEMPTS)
66
+
67
+
68
+ def _row(username: str) -> Optional[dict]:
69
+ return DatabaseManagerV2.execute_one(
70
+ "SELECT username, failures, last_failure_at FROM login_attempts WHERE username = :username",
71
+ {"username": username}
72
+ )
73
+
74
+
75
+ def _as_datetime(value) -> Optional[datetime]:
76
+ if value is None or isinstance(value, datetime):
77
+ return value
78
+ try:
79
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
80
+ except (TypeError, ValueError):
81
+ return None
82
+
83
+
84
+ def locked_until(username: str, now: Optional[datetime] = None) -> Optional[datetime]:
85
+ """When this name may try again, or None when it may try now."""
86
+ if not username:
87
+ return None
88
+
89
+ now = now or datetime.utcnow()
90
+ try:
91
+ row = _row(username)
92
+ except Exception as error:
93
+ # A throttle that cannot read its own table must not become the reason
94
+ # nobody can log in.
95
+ logger.warning(f"Could not read the login attempts of {username}: {error}")
96
+ return None
97
+
98
+ if not row or (row.get("failures") or 0) < max_attempts():
99
+ return None
100
+
101
+ last_failure = _as_datetime(row.get("last_failure_at"))
102
+ if last_failure is None:
103
+ return None
104
+
105
+ until = last_failure + lockout_window()
106
+ return until if until > now else None
107
+
108
+
109
+ def record_failure(username: str, now: Optional[datetime] = None) -> None:
110
+ """Count one failed attempt against this name."""
111
+ if not username:
112
+ return
113
+
114
+ now = now or datetime.utcnow()
115
+ try:
116
+ row = _row(username)
117
+ if row is None:
118
+ DatabaseManagerV2.execute_commit(
119
+ "INSERT INTO login_attempts (username, failures, last_failure_at) "
120
+ "VALUES (:username, 1, :now)",
121
+ {"username": username, "now": now}
122
+ )
123
+ return
124
+
125
+ # A name whose window has already passed starts its count again, so old
126
+ # failures do not add up to a lockout weeks later.
127
+ failures = (row.get("failures") or 0)
128
+ last_failure = _as_datetime(row.get("last_failure_at"))
129
+ if last_failure is not None and last_failure + lockout_window() <= now:
130
+ failures = 0
131
+
132
+ DatabaseManagerV2.execute_commit(
133
+ "UPDATE login_attempts SET failures = :failures, last_failure_at = :now "
134
+ "WHERE username = :username",
135
+ {"failures": failures + 1, "now": now, "username": username}
136
+ )
137
+ except Exception as error:
138
+ logger.warning(f"Could not record a failed login of {username}: {error}")
139
+
140
+
141
+ def record_success(username: str) -> None:
142
+ """Forget the failures of a name that has just got in."""
143
+ if not username:
144
+ return
145
+ try:
146
+ DatabaseManagerV2.execute_commit(
147
+ "DELETE FROM login_attempts WHERE username = :username",
148
+ {"username": username}
149
+ )
150
+ except Exception as error:
151
+ logger.warning(f"Could not clear the login attempts of {username}: {error}")
keepup/auth/oidc.py ADDED
@@ -0,0 +1,245 @@
1
+ """Signing in through an external identity provider, over OpenID Connect.
2
+
3
+ The person proves who they are to their provider, and this application never
4
+ sees their password. What comes back is a code, which is exchanged for an
5
+ ``id_token`` -- a signed statement by the provider about who just signed in.
6
+
7
+ Two things are worth knowing before reading further.
8
+
9
+ **Everything is checked before anybody is believed.** The order is: the state
10
+ we issued, then the exchange, then the signature against the provider's keys,
11
+ then issuer, audience, expiry and nonce. A failure at any point answers exactly
12
+ like a failure at any other; the reason goes to the log. Different answers for
13
+ different failures would tell whoever is probing which check they got past.
14
+
15
+ **Nothing of the provider's is kept.** The access and refresh tokens are not
16
+ stored: they are keys to somebody's account at the provider, and this
17
+ application has no business holding them. The ``id_token`` is read once and
18
+ dropped. What is kept is the pair (issuer, subject) -- who this is -- and that
19
+ is the whole point of the exchange.
20
+ """
21
+
22
+ import base64
23
+ import hashlib
24
+ import logging
25
+ import secrets
26
+ import time
27
+ from dataclasses import dataclass
28
+ from typing import Any, Dict, Optional, Tuple
29
+ from urllib.parse import urlencode
30
+
31
+ import httpx
32
+ from jose import JWTError, jwt
33
+
34
+ from keepup.auth.signing_key import resolve_signing_key
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ #: The cookie carrying the state of one sign-in attempt. SameSite=Lax rather
39
+ #: than Strict on purpose: the return from the provider is a navigation from
40
+ #: another site, and Strict would withhold the cookie exactly then -- that is,
41
+ #: always.
42
+ FLOW_COOKIE = "ss_oidc"
43
+ FLOW_COOKIE_MAX_AGE = 600
44
+
45
+ #: Symmetric algorithms and "none" are never accepted for an id_token: the
46
+ #: provider signs with its private key, and anything else means the token was
47
+ #: signed by whoever handed it to us.
48
+ ALLOWED_ALGORITHMS = ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256")
49
+
50
+ #: The one answer every refusal gives. See the module docstring.
51
+ REFUSAL = "Sign-in failed"
52
+
53
+
54
+ class SignInRefused(Exception):
55
+ """Raised for every failed check; the reason is for the log, not the caller."""
56
+
57
+
58
+ @dataclass
59
+ class ProviderMetadata:
60
+ """What the provider says about itself, from its discovery document."""
61
+
62
+ issuer: str
63
+ authorization_endpoint: str
64
+ token_endpoint: str
65
+ jwks_uri: str
66
+
67
+
68
+ class ProviderDirectory:
69
+ """The provider's metadata and signing keys, kept for the process.
70
+
71
+ Held in memory rather than in the database: none of it is secret, losing it
72
+ costs one request, and a shared copy would need invalidating across
73
+ replicas -- which costs more than it saves. Keys are re-read when a token
74
+ arrives signed by a key id we do not know, because providers rotate keys
75
+ without telling anyone and sign-in must not stay broken until a restart.
76
+ """
77
+
78
+ def __init__(self, settings, client_factory=None):
79
+ self._settings = settings
80
+ self._client_factory = client_factory or self._default_client
81
+ self._metadata: Optional[ProviderMetadata] = None
82
+ self._keys: Dict[str, Dict[str, Any]] = {}
83
+
84
+ def _default_client(self):
85
+ return httpx.AsyncClient(timeout=self._settings.timeout_seconds)
86
+
87
+ async def _get_json(self, url: str) -> Dict[str, Any]:
88
+ async with self._client_factory() as client:
89
+ response = await client.get(url)
90
+ response.raise_for_status()
91
+ return response.json()
92
+
93
+ async def metadata(self) -> ProviderMetadata:
94
+ """The discovery document, fetched once."""
95
+ if self._metadata is None:
96
+ url = self._settings.issuer.rstrip("/") + "/.well-known/openid-configuration"
97
+ document = await self._get_json(url)
98
+ self._metadata = ProviderMetadata(
99
+ issuer=document["issuer"],
100
+ authorization_endpoint=document["authorization_endpoint"],
101
+ token_endpoint=document["token_endpoint"],
102
+ jwks_uri=document["jwks_uri"],
103
+ )
104
+ logger.info(f"OIDC provider discovered: {self._metadata.issuer}")
105
+ return self._metadata
106
+
107
+ async def key_for(self, kid: Optional[str], refresh: bool = False) -> Dict[str, Any]:
108
+ """The signing key with this id, re-reading the key set if it is new."""
109
+ if refresh or not self._keys:
110
+ metadata = await self.metadata()
111
+ document = await self._get_json(metadata.jwks_uri)
112
+ self._keys = {key.get("kid"): key for key in document.get("keys", [])}
113
+ logger.debug(f"OIDC keys loaded: {sorted(k for k in self._keys if k)}")
114
+
115
+ if kid in self._keys:
116
+ return self._keys[kid]
117
+ if not refresh:
118
+ # A key we have not seen: the provider has rotated, not lied.
119
+ return await self.key_for(kid, refresh=True)
120
+ raise SignInRefused(f"the provider has no signing key {kid!r}")
121
+
122
+ def forget(self):
123
+ """Drop what is cached; for tests and for a changed configuration."""
124
+ self._metadata = None
125
+ self._keys = {}
126
+
127
+
128
+ def _b64url(raw: bytes) -> str:
129
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
130
+
131
+
132
+ def make_pkce_pair() -> Tuple[str, str]:
133
+ """A verifier that stays here and the challenge that goes to the provider."""
134
+ verifier = _b64url(secrets.token_bytes(32))
135
+ challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
136
+ return verifier, challenge
137
+
138
+
139
+ def issue_flow_token(state: str, nonce: str, verifier: str, now: Optional[int] = None) -> str:
140
+ """Pack one sign-in attempt into a short-lived signed value.
141
+
142
+ In a cookie rather than a row: a table of unfinished sign-ins would need a
143
+ row per attempt and a sweep for the abandoned ones, to hold what a signed
144
+ value holds by itself. One-time use is enforced by clearing the cookie at
145
+ the return.
146
+ """
147
+ issued = now if now is not None else int(time.time())
148
+ return jwt.encode(
149
+ {"state": state, "nonce": nonce, "verifier": verifier,
150
+ "iat": issued, "exp": issued + FLOW_COOKIE_MAX_AGE},
151
+ resolve_signing_key(),
152
+ algorithm="HS256",
153
+ )
154
+
155
+
156
+ def read_flow_token(token: Optional[str]) -> Dict[str, Any]:
157
+ """Unpack the attempt, refusing anything expired or not ours."""
158
+ if not token:
159
+ raise SignInRefused("no sign-in attempt cookie")
160
+ try:
161
+ return jwt.decode(token, resolve_signing_key(), algorithms=["HS256"])
162
+ except JWTError as error:
163
+ raise SignInRefused(f"sign-in attempt cookie rejected: {error}")
164
+
165
+
166
+ def authorization_url(metadata: ProviderMetadata, settings, state: str, nonce: str,
167
+ challenge: str) -> str:
168
+ """Where to send the browser to have the person identified."""
169
+ query = {
170
+ "response_type": "code",
171
+ "client_id": settings.client_id,
172
+ "redirect_uri": settings.redirect_uri,
173
+ "scope": " ".join(settings.scopes),
174
+ "state": state,
175
+ "nonce": nonce,
176
+ "code_challenge": challenge,
177
+ "code_challenge_method": "S256",
178
+ }
179
+ return f"{metadata.authorization_endpoint}?{urlencode(query)}"
180
+
181
+
182
+ async def exchange_code(directory: ProviderDirectory, settings, code: str,
183
+ verifier: str) -> Dict[str, Any]:
184
+ """Trade the code for tokens, proving we started this attempt.
185
+
186
+ The verifier proves it: it never left this server, and only the party that
187
+ began the attempt can produce it.
188
+ """
189
+ metadata = await directory.metadata()
190
+ form = {
191
+ "grant_type": "authorization_code",
192
+ "code": code,
193
+ "redirect_uri": settings.redirect_uri,
194
+ "client_id": settings.client_id,
195
+ "client_secret": settings.client_secret,
196
+ "code_verifier": verifier,
197
+ }
198
+ async with directory._client_factory() as client:
199
+ response = await client.post(metadata.token_endpoint, data=form)
200
+ if response.status_code != 200:
201
+ raise SignInRefused(f"the provider refused the code: {response.status_code}")
202
+ tokens = response.json()
203
+ if "id_token" not in tokens:
204
+ raise SignInRefused("the provider returned no id_token")
205
+ return tokens
206
+
207
+
208
+ async def verify_id_token(directory: ProviderDirectory, settings, id_token: str,
209
+ nonce: str) -> Dict[str, Any]:
210
+ """Check the provider's statement, and refuse it on any doubt.
211
+
212
+ Checked here rather than trusted: an id_token is a bearer statement, and
213
+ the only thing making it worth anything is that the signature belongs to
214
+ the issuer we were configured with.
215
+ """
216
+ try:
217
+ header = jwt.get_unverified_header(id_token)
218
+ except JWTError as error:
219
+ raise SignInRefused(f"unreadable id_token: {error}")
220
+
221
+ algorithm = header.get("alg")
222
+ if algorithm not in ALLOWED_ALGORITHMS:
223
+ raise SignInRefused(f"id_token signed with {algorithm!r}")
224
+
225
+ key = await directory.key_for(header.get("kid"))
226
+ metadata = await directory.metadata()
227
+
228
+ try:
229
+ claims = jwt.decode(
230
+ id_token,
231
+ key,
232
+ algorithms=[algorithm],
233
+ audience=settings.client_id,
234
+ issuer=metadata.issuer,
235
+ options={"verify_at_hash": False},
236
+ )
237
+ except JWTError as error:
238
+ raise SignInRefused(f"id_token rejected: {error}")
239
+
240
+ if claims.get("nonce") != nonce:
241
+ raise SignInRefused("id_token carries another attempt's nonce")
242
+ if not claims.get("sub"):
243
+ raise SignInRefused("id_token names no subject")
244
+
245
+ return claims
@@ -0,0 +1,111 @@
1
+ """What to do with somebody the application has never seen before.
2
+
3
+ The provider has just proved who this person is. It has not said anything
4
+ about whether they may use this product -- that is not a question an identity
5
+ provider can answer, and the framework cannot answer it either: for one
6
+ application everyone at the provider is welcome, for another access is by
7
+ invitation and the provider is only a way of proving a name.
8
+
9
+ So the decision belongs to the application, and the framework's default is to
10
+ refuse. The opposite default would turn switching a provider on into open
11
+ registration for a product that never asked for it -- and it would do so
12
+ quietly, which is the worst way for it to happen.
13
+ """
14
+
15
+ import logging
16
+ from dataclasses import dataclass
17
+ from typing import Any, Callable, Dict, Iterable, Optional
18
+
19
+ from keepup.roles import ROLE_CLIENT
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class AccountDecision:
26
+ """Either "no", or the account to create."""
27
+
28
+ admit: bool
29
+ role: str = ROLE_CLIENT
30
+ status: str = "active"
31
+ #: Why, for the log. Never shown to whoever is signing in.
32
+ reason: str = ""
33
+
34
+ @classmethod
35
+ def refuse(cls, reason: str) -> "AccountDecision":
36
+ return cls(admit=False, reason=reason)
37
+
38
+ @classmethod
39
+ def create(cls, role: str = ROLE_CLIENT, status: str = "active",
40
+ reason: str = "") -> "AccountDecision":
41
+ return cls(admit=True, role=role, status=status, reason=reason)
42
+
43
+
44
+ def refuse_unknown() -> Callable[[Dict[str, Any]], AccountDecision]:
45
+ """The default: prove who you are all you like, there is no account here."""
46
+
47
+ def decide(claims: Dict[str, Any]) -> AccountDecision:
48
+ return AccountDecision.refuse("the application admits nobody it does not already know")
49
+
50
+ return decide
51
+
52
+
53
+ def create_account(role: str = ROLE_CLIENT,
54
+ status: str = "active") -> Callable[[Dict[str, Any]], AccountDecision]:
55
+ """Anyone the provider vouches for gets an account.
56
+
57
+ Reasonable when the provider is the company's own directory and everybody
58
+ behind it is staff. Open registration when the provider is a public one --
59
+ which is the application's call to make, deliberately.
60
+ """
61
+
62
+ def decide(claims: Dict[str, Any]) -> AccountDecision:
63
+ return AccountDecision.create(role=role, status=status,
64
+ reason="the application admits everyone the provider vouches for")
65
+
66
+ return decide
67
+
68
+
69
+ def create_if_email_domain(domains: Iterable[str], role: str = ROLE_CLIENT,
70
+ status: str = "active") -> Callable[[Dict[str, Any]], AccountDecision]:
71
+ """An account for staff, nothing for everyone else at the same provider.
72
+
73
+ The email is compared only when the provider says it verified it: an
74
+ unverified address is a string the person typed, and several public
75
+ providers will hand it over unchecked.
76
+ """
77
+ allowed = {domain.lower().lstrip("@") for domain in domains}
78
+
79
+ def decide(claims: Dict[str, Any]) -> AccountDecision:
80
+ email = (claims.get("email") or "").lower()
81
+ if not email or "@" not in email:
82
+ return AccountDecision.refuse("no email in the provider's claims")
83
+ if claims.get("email_verified") is False:
84
+ return AccountDecision.refuse("the provider did not verify this email")
85
+ domain = email.rsplit("@", 1)[1]
86
+ if domain not in allowed:
87
+ return AccountDecision.refuse(f"email domain {domain!r} is not admitted")
88
+ return AccountDecision.create(role=role, status=status,
89
+ reason=f"email domain {domain!r} is admitted")
90
+
91
+ return decide
92
+
93
+
94
+ def decide(policy: Optional[Callable], claims: Dict[str, Any]) -> AccountDecision:
95
+ """Ask the application, and refuse if it has not said anything.
96
+
97
+ A policy that raises is a refusal too: an application whose rule fell over
98
+ has not admitted anybody, and treating the failure as consent is how a door
99
+ opens by accident.
100
+ """
101
+ if policy is None:
102
+ return refuse_unknown()(claims)
103
+ try:
104
+ decision = policy(claims)
105
+ except Exception as error:
106
+ logger.error(f"The account policy raised; refusing: {error}")
107
+ return AccountDecision.refuse("the application's policy failed")
108
+ if not isinstance(decision, AccountDecision):
109
+ logger.error(f"The account policy returned {type(decision).__name__}, not a decision; refusing")
110
+ return AccountDecision.refuse("the application's policy answered nothing usable")
111
+ return decision