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,288 @@
1
+ """The two endpoints of the external sign-in, and what happens between them.
2
+
3
+ ``/api/auth/oidc/login`` sends the browser to the provider;
4
+ ``/api/auth/oidc/callback`` receives it back, checks everything
5
+ (:mod:`keepup.auth.oidc`), decides whether somebody unknown may have an account
6
+ (:mod:`keepup.auth.oidc_policy`), and ends by issuing the ordinary session of
7
+ this application -- the same token and the same cookie a local sign-in gives,
8
+ so the panel, the renewal and the logout know nothing about how the person got
9
+ in.
10
+
11
+ Registered only when the application configured a provider. Without one the
12
+ paths do not exist: the possibility is off, not broken, and a 404 there is the
13
+ truthful answer.
14
+ """
15
+
16
+ import logging
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from fastapi import HTTPException, Request, Response, status
20
+ from fastapi.responses import RedirectResponse
21
+
22
+ from keepup.auth import oidc, oidc_policy, panel_session
23
+ from keepup.auth.dependencies import issue_session_token
24
+ from keepup.db import DatabaseManager, DatabaseManagerV2, db_config
25
+ from keepup.roles import ROLE_ADMIN, ROLE_CLIENT
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ #: Permissions that make somebody an administrator here. Mapping a claim onto
30
+ #: one of them is how a provider's role becomes a role in this application.
31
+ ADMIN_PERMISSIONS = ("admin", "manage_users", "manage_settings")
32
+
33
+
34
+ def _refuse(reason: str) -> HTTPException:
35
+ """One answer for every failed check; the reason stays in the log.
36
+
37
+ Telling the caller which check failed says which ones they passed, and that
38
+ is a map for whoever is probing.
39
+ """
40
+ logger.warning(f"OIDC sign-in refused: {reason}")
41
+ return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=oidc.REFUSAL)
42
+
43
+
44
+ # --- the account behind the claims --------------------------------------------
45
+
46
+ def find_account(issuer: str, subject: str) -> Optional[Dict[str, Any]]:
47
+ """The account tied to this provider's subject, if there is one.
48
+
49
+ Looked up by subject rather than by email: an address at a provider changes
50
+ hands, and matching on it would let a new employee into the account of the
51
+ person whose address they inherited.
52
+ """
53
+ rows = DatabaseManagerV2.execute(
54
+ "SELECT id, username, status, role FROM users "
55
+ "WHERE auth_source = :source AND external_id = :subject",
56
+ {"source": issuer, "subject": str(subject)},
57
+ )
58
+ return dict(rows[0]) if rows else None
59
+
60
+
61
+ def propose_username(claims: Dict[str, Any]) -> str:
62
+ """A name for the new account, taken from what the provider said."""
63
+ for claim in ("preferred_username", "email", "sub"):
64
+ value = claims.get(claim)
65
+ if value:
66
+ return str(value).strip().lower()
67
+ return "user"
68
+
69
+
70
+ def free_username(proposed: str) -> str:
71
+ """The proposed name, or the first free variant of it.
72
+
73
+ Names are unique here and come from somewhere else entirely; two providers,
74
+ or two people at one provider, can propose the same one.
75
+ """
76
+ candidate = proposed
77
+ suffix = 1
78
+ while DatabaseManagerV2.execute_one(
79
+ "SELECT id FROM users WHERE username = :name", {"name": candidate}):
80
+ suffix += 1
81
+ candidate = f"{proposed}-{suffix}"
82
+ return candidate
83
+
84
+
85
+ def create_account(issuer: str, claims: Dict[str, Any],
86
+ decision: oidc_policy.AccountDecision) -> Dict[str, Any]:
87
+ """Create the account the application's policy agreed to.
88
+
89
+ The password column is filled with an unusable value rather than left
90
+ empty: this account has no password here, and a blank one is something a
91
+ local sign-in might one day accept.
92
+ """
93
+ username = free_username(propose_username(claims))
94
+ DatabaseManagerV2.execute_commit(
95
+ "INSERT INTO users (username, password_hash, status, role, email, full_name, "
96
+ "auth_source, external_id, agree_terms) "
97
+ "VALUES (:username, :password_hash, :status, :role, :email, :full_name, "
98
+ ":source, :subject, :agree_terms)",
99
+ {
100
+ "username": username,
101
+ "password_hash": "!external",
102
+ "status": decision.status,
103
+ "role": decision.role,
104
+ "email": claims.get("email"),
105
+ "full_name": claims.get("name"),
106
+ "source": issuer,
107
+ "subject": str(claims["sub"]),
108
+ "agree_terms": False,
109
+ },
110
+ )
111
+ logger.info(f"OIDC account created for {issuer} subject: {username} ({decision.reason})")
112
+ account = find_account(issuer, claims["sub"])
113
+ if account is None:
114
+ raise _refuse("the account was created but cannot be read back")
115
+ return account
116
+
117
+
118
+ # --- roles out of the claims ---------------------------------------------------
119
+
120
+ def external_roles(claims: Dict[str, Any], roles_claim: str) -> List[str]:
121
+ """The provider's roles, however this provider chose to shape them."""
122
+ value = claims.get(roles_claim)
123
+ if value is None:
124
+ return []
125
+ if isinstance(value, str):
126
+ return [part for part in value.replace(",", " ").split() if part]
127
+ if isinstance(value, (list, tuple)):
128
+ return [str(part) for part in value if part]
129
+ return []
130
+
131
+
132
+ def permissions_for(issuer: str, roles: List[str]) -> Dict[str, bool]:
133
+ """Map the provider's roles onto permissions here.
134
+
135
+ Through the same table the directory provider used, keyed by the issuer:
136
+ which outside role means what is a property of the deployment, not of the
137
+ code.
138
+ """
139
+ granted: Dict[str, bool] = {}
140
+ for role in roles:
141
+ rows = DatabaseManagerV2.execute(
142
+ "SELECT internal_permission_name FROM external_role_mappings "
143
+ "WHERE auth_source = :source AND external_role_name = :role",
144
+ {"source": issuer, "role": role},
145
+ )
146
+ for row in rows:
147
+ granted[row["internal_permission_name"]] = True
148
+ return granted
149
+
150
+
151
+ def apply_permissions(user_id: int, permissions: Dict[str, bool]) -> None:
152
+ """Write this sign-in's permissions, replacing what was there.
153
+
154
+ Replaced rather than added to: a permission taken away at the provider has
155
+ to disappear here as well, and that is the whole reason a central sign-in
156
+ is worth having.
157
+ """
158
+ DatabaseManagerV2.execute_commit(
159
+ "DELETE FROM user_permissions WHERE user_id = :user_id", {"user_id": user_id})
160
+ for name, granted in permissions.items():
161
+ DatabaseManagerV2.execute_commit(
162
+ "INSERT INTO user_permissions (user_id, permission_name, granted) "
163
+ "VALUES (:user_id, :name, :granted)",
164
+ {"user_id": user_id, "name": name, "granted": bool(granted)},
165
+ )
166
+
167
+
168
+ def role_from_permissions(permissions: Dict[str, bool], default_role: str) -> str:
169
+ """An administrator is somebody holding an administrative permission."""
170
+ if any(permissions.get(name) for name in ADMIN_PERMISSIONS):
171
+ return ROLE_ADMIN
172
+ return default_role or ROLE_CLIENT
173
+
174
+
175
+ def sync_roles(account: Dict[str, Any], issuer: str, claims: Dict[str, Any],
176
+ settings) -> str:
177
+ """Recompute what this person may do, from what the provider said just now."""
178
+ permissions = permissions_for(issuer, external_roles(claims, settings.roles_claim))
179
+ apply_permissions(account["id"], permissions)
180
+
181
+ role = role_from_permissions(permissions, settings.default_role)
182
+ if role != account.get("role"):
183
+ DatabaseManagerV2.execute_commit(
184
+ "UPDATE users SET role = :role WHERE id = :user_id",
185
+ {"role": role, "user_id": account["id"]},
186
+ )
187
+ logger.info(f"OIDC role for {account['username']}: {account.get('role')} -> {role}")
188
+ return role
189
+
190
+
191
+ # --- the endpoints -------------------------------------------------------------
192
+
193
+ def register_oidc_routes(app, settings) -> None:
194
+ """Register external sign-in, if this application configured a provider."""
195
+ if settings.oidc is None:
196
+ return
197
+
198
+ provider = settings.oidc
199
+ directory = oidc.ProviderDirectory(provider)
200
+ app.state.oidc_directory = directory
201
+
202
+ @app.get("/api/auth/oidc/login", include_in_schema=False)
203
+ async def oidc_login(request: Request):
204
+ """Send the browser to the provider to have the person identified.
205
+
206
+ Takes the request only to decide whether the flow cookie may be marked
207
+ Secure -- the same question the session cookie asks.
208
+ """
209
+ try:
210
+ metadata = await directory.metadata()
211
+ except Exception as error:
212
+ logger.error(f"OIDC provider unreachable: {error}")
213
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
214
+ detail="The identity provider is unavailable")
215
+
216
+ state = oidc.secrets.token_urlsafe(16)
217
+ nonce = oidc.secrets.token_urlsafe(16)
218
+ verifier, challenge = oidc.make_pkce_pair()
219
+
220
+ response = RedirectResponse(
221
+ oidc.authorization_url(metadata, provider, state, nonce, challenge),
222
+ status_code=status.HTTP_303_SEE_OTHER,
223
+ )
224
+ response.set_cookie(
225
+ oidc.FLOW_COOKIE,
226
+ oidc.issue_flow_token(state, nonce, verifier),
227
+ max_age=oidc.FLOW_COOKIE_MAX_AGE,
228
+ path="/api/auth/oidc",
229
+ httponly=True,
230
+ samesite="lax",
231
+ # It carries the PKCE verifier, the nonce and the state. This was
232
+ # False unconditionally -- not even asking, the way the session
233
+ # cookie does -- so on an HTTPS deployment it still went out over a
234
+ # forced plain request to the same host (task keepup-14).
235
+ secure=panel_session.is_https(request),
236
+ )
237
+ return response
238
+
239
+ @app.get("/api/auth/oidc/callback", include_in_schema=False)
240
+ async def oidc_callback(request: Request, code: str = None, state: str = None,
241
+ error: str = None):
242
+ """Receive the person back and, if everything checks out, let them in."""
243
+ if error:
244
+ raise _refuse(f"the provider answered with an error: {error}")
245
+ if not code or not state:
246
+ raise _refuse("the return carries no code or no state")
247
+
248
+ try:
249
+ flow = oidc.read_flow_token(request.cookies.get(oidc.FLOW_COOKIE))
250
+ if flow.get("state") != state:
251
+ raise oidc.SignInRefused("the state does not belong to this attempt")
252
+
253
+ tokens = await oidc.exchange_code(directory, provider, code, flow["verifier"])
254
+ claims = await oidc.verify_id_token(directory, provider,
255
+ tokens["id_token"], flow["nonce"])
256
+ except oidc.SignInRefused as refusal:
257
+ raise _refuse(str(refusal))
258
+ except Exception as error:
259
+ raise _refuse(f"the exchange failed: {error}")
260
+
261
+ issuer = (await directory.metadata()).issuer
262
+ account = find_account(issuer, claims["sub"])
263
+
264
+ if account is None:
265
+ decision = oidc_policy.decide(settings.oidc_account_policy, claims)
266
+ if not decision.admit:
267
+ raise _refuse(f"policy refused a new account: {decision.reason}")
268
+ account = create_account(issuer, claims, decision)
269
+
270
+ if account.get("status") != "active":
271
+ # Proving who you are is not the same as being allowed in.
272
+ raise _refuse(f"the account {account['username']} is not active")
273
+
274
+ sync_roles(account, issuer, claims, provider)
275
+
276
+ issued = issue_session_token(account["id"], account["username"])
277
+ response = RedirectResponse(provider.after_login_path,
278
+ status_code=status.HTTP_303_SEE_OTHER)
279
+ panel_session.set_cookies(response, request, issued["access_token"],
280
+ issued["expires_in"])
281
+ # One attempt, one use: the cookie is spent, so a replayed return finds
282
+ # nothing to check itself against.
283
+ response.delete_cookie(oidc.FLOW_COOKIE, path="/api/auth/oidc")
284
+
285
+ logger.info(f"OIDC sign-in: {account['username']} via {issuer}")
286
+ if settings.record_login is not None:
287
+ await settings.record_login(dict(account))
288
+ return response
@@ -0,0 +1,220 @@
1
+ """The panel session: a server-side record, carried in an httpOnly cookie.
2
+
3
+ The token used to live in localStorage, where any script on the page could read
4
+ it and take the whole session away with it; logging out only forgot it in that
5
+ one browser, and a copied token stayed good for a day. Now a session is a row,
6
+ the token names it (`sid`), and every request looks the row up -- so a logout, a
7
+ password change or a block takes effect on every replica at once.
8
+
9
+ The browser never sees the token: it sits in `ss_session` (HttpOnly). The panel
10
+ keeps the non-secret marker `cookie` where the token used to be, so the many
11
+ sections that check "is there a token" and send `Bearer <token>` keep working;
12
+ the server reads such a header as no header at all and falls back to the cookie.
13
+
14
+ A cookie is sent by the browser on its own, so a request authenticated by it must
15
+ also prove it came from the panel: the double-submit `ss_csrf` cookie, readable by
16
+ the page, echoed in `X-CSRF-Token`. A real `Authorization: Bearer` (an agent, a
17
+ script) needs no such proof -- another site cannot set that header.
18
+ """
19
+
20
+ import logging
21
+ import os
22
+ import secrets
23
+ from datetime import datetime, timedelta
24
+ from typing import Optional
25
+ from urllib.parse import urlparse
26
+
27
+ from fastapi import HTTPException, Request
28
+ from sqlalchemy import Column, DateTime, Index, Integer, String, Text
29
+ from starlette import status
30
+
31
+ from keepup import tables
32
+ from keepup.db import DatabaseManagerV2
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ TABLE = "auth_session"
37
+ SESSION_CLAIM = "sid"
38
+ SESSION_COOKIE = "ss_session"
39
+ CSRF_COOKIE = "ss_csrf"
40
+ CSRF_HEADER = "X-CSRF-Token"
41
+
42
+ #: What the panel keeps in localStorage instead of the token, and the values a
43
+ #: section sends when it had nothing there. None of them is a credential.
44
+ BEARER_PLACEHOLDERS = frozenset({"", "cookie", "null", "undefined"})
45
+ SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
46
+
47
+ REASON_LOGOUT = "logout"
48
+ REASON_PASSWORD_CHANGED = "password_changed"
49
+ REASON_ACCOUNT_BLOCKED = "account_blocked"
50
+
51
+
52
+ AUTH_SESSION = tables.table(
53
+ TABLE,
54
+ Column("sid", String(64).with_variant(Text(), "sqlite"), primary_key=True, nullable=True),
55
+ Column("user_id", Integer, nullable=False),
56
+ Column("created_at", DateTime, nullable=False),
57
+ Column("expires_at", DateTime, nullable=False),
58
+ Column("revoked_at", DateTime),
59
+ Column("revoked_reason", String(64).with_variant(Text(), "sqlite")),
60
+ Index(f"idx_{TABLE}_user", "user_id"),
61
+ )
62
+
63
+
64
+ def init_table() -> None:
65
+ tables.ensure_tables(AUTH_SESSION)
66
+ # Each start is often enough: a row past its end is only kept for inquiry.
67
+ purge_expired()
68
+
69
+
70
+ # --- the record ------------------------------------------------------------------------------
71
+
72
+ def open_session(user_id: int, lifetime: timedelta, now: Optional[datetime] = None) -> str:
73
+ now = now or datetime.utcnow()
74
+ sid = secrets.token_urlsafe(32)
75
+ DatabaseManagerV2.execute_commit(
76
+ f"INSERT INTO {TABLE} (sid, user_id, created_at, expires_at) "
77
+ f"VALUES (:sid, :user_id, :now, :expires_at)",
78
+ {"sid": sid, "user_id": user_id, "now": now, "expires_at": now + lifetime})
79
+ return sid
80
+
81
+
82
+ def extend(sid: str, lifetime: timedelta, now: Optional[datetime] = None) -> None:
83
+ """A renewal keeps the session and moves its end, like the token it renews."""
84
+ now = now or datetime.utcnow()
85
+ DatabaseManagerV2.execute_commit(
86
+ f"UPDATE {TABLE} SET expires_at = :expires_at WHERE sid = :sid AND revoked_at IS NULL",
87
+ {"sid": sid, "expires_at": now + lifetime})
88
+
89
+
90
+ def is_active(sid: str, user_id: Optional[int] = None) -> bool:
91
+ row = DatabaseManagerV2.execute_one(
92
+ f"SELECT user_id, revoked_at FROM {TABLE} WHERE sid = :sid", {"sid": sid})
93
+ if not row or row.get("revoked_at") is not None:
94
+ return False
95
+ # A session is somebody's: a token whose name and session disagree was not
96
+ # issued by this server.
97
+ return user_id is None or int(row["user_id"]) == int(user_id)
98
+
99
+
100
+ def revoke(sid: str, reason: str = REASON_LOGOUT) -> int:
101
+ return DatabaseManagerV2.execute_commit(
102
+ f"UPDATE {TABLE} SET revoked_at = :now, revoked_reason = :reason "
103
+ f"WHERE sid = :sid AND revoked_at IS NULL",
104
+ {"sid": sid, "now": datetime.utcnow(), "reason": reason})
105
+
106
+
107
+ def revoke_all(user_id: int, reason: str) -> int:
108
+ return DatabaseManagerV2.execute_commit(
109
+ f"UPDATE {TABLE} SET revoked_at = :now, revoked_reason = :reason "
110
+ f"WHERE user_id = :u AND revoked_at IS NULL",
111
+ {"u": user_id, "now": datetime.utcnow(), "reason": reason})
112
+
113
+
114
+ def purge_expired(now: Optional[datetime] = None, keep: timedelta = timedelta(days=7)) -> int:
115
+ """Rows past their end serve nothing; a week is kept for whoever investigates."""
116
+ return DatabaseManagerV2.execute_commit(
117
+ f"DELETE FROM {TABLE} WHERE expires_at < :cutoff",
118
+ {"cutoff": (now or datetime.utcnow()) - keep})
119
+
120
+
121
+ # --- where the token comes from --------------------------------------------------------------
122
+
123
+ def real_bearer(value: Optional[str]) -> Optional[str]:
124
+ """The bearer token, or None when the header only carries a placeholder."""
125
+ if value is None or value.strip() in BEARER_PLACEHOLDERS:
126
+ return None
127
+ return value.strip()
128
+
129
+
130
+ def token_from_request(request: Request, bearer: Optional[str]) -> Optional[str]:
131
+ """The token of this request: a real bearer first, the session cookie second.
132
+
133
+ A cookie-authenticated request that changes something must carry the CSRF
134
+ header; without it the answer is 403, not a quiet fall-through to anonymous.
135
+ """
136
+ token = real_bearer(bearer)
137
+ if token:
138
+ return token
139
+ token = request.cookies.get(SESSION_COOKIE)
140
+ if not token:
141
+ return None
142
+ if request.method.upper() not in SAFE_METHODS and not csrf_matches(request):
143
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
144
+ detail="CSRF token missing or invalid")
145
+ return token
146
+
147
+
148
+ def csrf_matches(request: Request) -> bool:
149
+ expected = request.cookies.get(CSRF_COOKIE)
150
+ presented = request.headers.get(CSRF_HEADER)
151
+ return bool(expected and presented and secrets.compare_digest(expected, presented))
152
+
153
+
154
+ def websocket_token(query_token: Optional[str], cookies) -> Optional[str]:
155
+ """A websocket has no CSRF header to send; `same_origin` stands in for it."""
156
+ return real_bearer(query_token) or (cookies or {}).get(SESSION_COOKIE)
157
+
158
+
159
+ def same_origin(headers) -> bool:
160
+ """Whether the page that opened a websocket is served from this server.
161
+
162
+ Compared with the host the request names and with the public address: a proxy
163
+ that does not pass `Host` on still leaves the public address to match.
164
+ """
165
+ origin = urlparse((headers.get("origin") or "").strip()).netloc.lower()
166
+ if not origin:
167
+ return False
168
+ names = {(headers.get("x-forwarded-host") or "").split(",")[0].strip().lower(),
169
+ (headers.get("host") or "").strip().lower()}
170
+ api_host = (os.environ.get("API_HOST") or "").strip().lower()
171
+ if api_host:
172
+ names.add(urlparse(api_host if "://" in api_host else f"//{api_host}").netloc)
173
+ return origin in names - {""}
174
+
175
+
176
+ # --- the cookies -----------------------------------------------------------------------------
177
+
178
+ #: Set on a deployment behind a proxy that terminates TLS and does not say so.
179
+ #: Without it the cookie goes out without Secure and travels over plain HTTP
180
+ #: the first time somebody types the address without the scheme -- and there
181
+ #: was no way to insist (task keepup-14).
182
+ FORCE_SECURE_COOKIES_ENV = "FORCE_SECURE_COOKIES"
183
+
184
+
185
+ def is_https(request: Request) -> bool:
186
+ """Whether the browser reached us over HTTPS -- the proxy says so, or the socket.
187
+
188
+ Not guessed from API_HOST: a direct http visit on the local network would then
189
+ get a Secure cookie the browser refuses to keep, and never stay signed in.
190
+ A deployment that knows better says so with FORCE_SECURE_COOKIES.
191
+
192
+ Args:
193
+ request: the incoming request.
194
+
195
+ Returns:
196
+ Whether the cookie may be marked Secure.
197
+ """
198
+ if os.getenv(FORCE_SECURE_COOKIES_ENV, "false").lower() == "true":
199
+ return True
200
+ forwarded = (request.headers.get("x-forwarded-proto") or "").split(",")[0].strip().lower()
201
+ if forwarded:
202
+ return forwarded == "https"
203
+ return request.url.scheme == "https"
204
+
205
+
206
+ def set_cookies(response, request: Request, token: str, max_age: int) -> str:
207
+ secure = is_https(request)
208
+ response.set_cookie(SESSION_COOKIE, token, max_age=max_age, path="/",
209
+ httponly=True, secure=secure, samesite="lax")
210
+ # Kept across renewals: a panel tab holding the old value would otherwise
211
+ # have its next change refused.
212
+ csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(32)
213
+ response.set_cookie(CSRF_COOKIE, csrf, max_age=max_age, path="/",
214
+ httponly=False, secure=secure, samesite="lax")
215
+ return csrf
216
+
217
+
218
+ def clear_cookies(response) -> None:
219
+ response.delete_cookie(SESSION_COOKIE, path="/")
220
+ response.delete_cookie(CSRF_COOKIE, path="/")
@@ -0,0 +1,59 @@
1
+ """Finer-grained rights than the two roles.
2
+
3
+ `keepup.roles` has ADMIN and CLIENT, which is enough to decide who may reach an
4
+ administrative route and not enough for anything else. A permission is checked
5
+ per action, by decorator or by dependency, so a route can be opened to a
6
+ non-administrator without opening the section around it.
7
+ """
8
+
9
+ from functools import wraps
10
+ from fastapi import HTTPException, status, Depends
11
+
12
+ from keepup.roles import ROLE_ADMIN
13
+ from keepup.auth.dependencies import get_all_users, get_current_user
14
+
15
+
16
+ def require_permission(permission_name: str):
17
+ """Decorator checking that the user holds the given permission."""
18
+
19
+ def decorator(func):
20
+ @wraps(func)
21
+ async def wrapper(*args, **kwargs):
22
+ current_user = kwargs.get('current_user')
23
+
24
+ if not current_user:
25
+ for arg in args:
26
+ if isinstance(arg, dict) and 'username' in arg:
27
+ current_user = arg
28
+ break
29
+
30
+ if not current_user:
31
+ raise HTTPException(
32
+ status_code=status.HTTP_401_UNAUTHORIZED,
33
+ detail="Authentication required"
34
+ )
35
+
36
+ permissions = current_user.get('permissions', {})
37
+
38
+ if not permissions.get(permission_name, False):
39
+ raise HTTPException(
40
+ status_code=status.HTTP_403_FORBIDDEN,
41
+ detail=f"Permission '{permission_name}' required"
42
+ )
43
+
44
+ return await func(*args, **kwargs)
45
+
46
+ return wrapper
47
+
48
+ return decorator
49
+
50
+
51
+ # Example of how this is used on an endpoint:
52
+ #@app.get("/api/admin/users")
53
+ @require_permission(ROLE_ADMIN)
54
+ async def get_all_users_endpoint(
55
+ current_user: dict = Depends(get_current_user)
56
+ ):
57
+ users = (
58
+ get_all_users())
59
+ return users
File without changes