commitguardian 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 (197) hide show
  1. commitguard/__init__.py +26 -0
  2. commitguard/__main__.py +6 -0
  3. commitguard/api/__init__.py +18 -0
  4. commitguard/api/app.py +1376 -0
  5. commitguard/api/governance.py +1085 -0
  6. commitguard/api/hosting.py +196 -0
  7. commitguard/api/http.py +252 -0
  8. commitguard/api/settings.py +169 -0
  9. commitguard/audit/__init__.py +13 -0
  10. commitguard/audit/logger.py +34 -0
  11. commitguard/audit/models.py +222 -0
  12. commitguard/audit/storage.py +59 -0
  13. commitguard/ci/__init__.py +7 -0
  14. commitguard/ci/context.py +60 -0
  15. commitguard/cli/__init__.py +6 -0
  16. commitguard/cli/app.py +74 -0
  17. commitguard/cli/commands/__init__.py +1 -0
  18. commitguard/cli/commands/benchmark.py +441 -0
  19. commitguard/cli/commands/check.py +100 -0
  20. commitguard/cli/commands/ci.py +165 -0
  21. commitguard/cli/commands/dashboard.py +141 -0
  22. commitguard/cli/commands/doctor.py +533 -0
  23. commitguard/cli/commands/github.py +449 -0
  24. commitguard/cli/commands/hook.py +156 -0
  25. commitguard/cli/commands/init.py +137 -0
  26. commitguard/cli/commands/install.py +152 -0
  27. commitguard/cli/commands/policy.py +36 -0
  28. commitguard/cli/commands/report.py +39 -0
  29. commitguard/cli/commands/reproduce.py +123 -0
  30. commitguard/cli/commands/scan.py +47 -0
  31. commitguard/cli/common.py +44 -0
  32. commitguard/cli/output.py +89 -0
  33. commitguard/cli/render.py +367 -0
  34. commitguard/config/__init__.py +6 -0
  35. commitguard/config/defaults.py +53 -0
  36. commitguard/config/enforcement.py +53 -0
  37. commitguard/config/loader.py +174 -0
  38. commitguard/config/schema.py +105 -0
  39. commitguard/config/sources.py +183 -0
  40. commitguard/controlplane/__init__.py +24 -0
  41. commitguard/controlplane/access.py +231 -0
  42. commitguard/controlplane/commands.py +393 -0
  43. commitguard/controlplane/errors.py +88 -0
  44. commitguard/controlplane/identity.py +478 -0
  45. commitguard/controlplane/members.py +219 -0
  46. commitguard/controlplane/notifications.py +787 -0
  47. commitguard/controlplane/pagination.py +146 -0
  48. commitguard/controlplane/policies.py +1204 -0
  49. commitguard/controlplane/queries.py +1814 -0
  50. commitguard/controlplane/results.py +909 -0
  51. commitguard/controlplane/rules.py +184 -0
  52. commitguard/controlplane/views.py +799 -0
  53. commitguard/core/__init__.py +6 -0
  54. commitguard/core/context.py +31 -0
  55. commitguard/core/decision.py +58 -0
  56. commitguard/core/engine.py +82 -0
  57. commitguard/core/result.py +177 -0
  58. commitguard/detectors/__init__.py +6 -0
  59. commitguard/detectors/base.py +58 -0
  60. commitguard/detectors/bot.py +87 -0
  61. commitguard/detectors/coauthor.py +86 -0
  62. commitguard/detectors/identity.py +76 -0
  63. commitguard/detectors/registry.py +72 -0
  64. commitguard/detectors/trailer.py +211 -0
  65. commitguard/exceptions/__init__.py +33 -0
  66. commitguard/exceptions/base.py +9 -0
  67. commitguard/exceptions/configuration.py +22 -0
  68. commitguard/exceptions/detection.py +11 -0
  69. commitguard/exceptions/git.py +41 -0
  70. commitguard/exceptions/service.py +25 -0
  71. commitguard/git/__init__.py +12 -0
  72. commitguard/git/commands.py +101 -0
  73. commitguard/git/commit.py +97 -0
  74. commitguard/git/diff.py +36 -0
  75. commitguard/git/hooks.py +527 -0
  76. commitguard/git/push.py +93 -0
  77. commitguard/git/ranges.py +71 -0
  78. commitguard/git/repository.py +447 -0
  79. commitguard/github/__init__.py +34 -0
  80. commitguard/github/actions.py +163 -0
  81. commitguard/github/app.py +935 -0
  82. commitguard/github/auth.py +217 -0
  83. commitguard/github/check_runs.py +172 -0
  84. commitguard/github/checks.py +210 -0
  85. commitguard/github/client.py +844 -0
  86. commitguard/github/enforcement_status.py +209 -0
  87. commitguard/github/errors.py +129 -0
  88. commitguard/github/events.py +563 -0
  89. commitguard/github/identifiers.py +90 -0
  90. commitguard/github/installations.py +566 -0
  91. commitguard/github/markdown.py +19 -0
  92. commitguard/github/permissions.py +70 -0
  93. commitguard/github/pull_requests.py +53 -0
  94. commitguard/github/queue.py +47 -0
  95. commitguard/github/recovery.py +124 -0
  96. commitguard/github/repositories.py +305 -0
  97. commitguard/github/server.py +52 -0
  98. commitguard/github/settings.py +174 -0
  99. commitguard/github/storage.py +2315 -0
  100. commitguard/github/webhooks.py +129 -0
  101. commitguard/github/worker.py +628 -0
  102. commitguard/github/workflow.py +286 -0
  103. commitguard/governance/__init__.py +26 -0
  104. commitguard/governance/bulk.py +765 -0
  105. commitguard/governance/cache.py +88 -0
  106. commitguard/governance/common.py +216 -0
  107. commitguard/governance/exceptions.py +861 -0
  108. commitguard/governance/groups.py +448 -0
  109. commitguard/governance/inventory.py +386 -0
  110. commitguard/governance/posture.py +1272 -0
  111. commitguard/governance/resolver.py +632 -0
  112. commitguard/governance/rollouts.py +760 -0
  113. commitguard/governance/rules.py +371 -0
  114. commitguard/governance/schedules.py +663 -0
  115. commitguard/governance/service.py +120 -0
  116. commitguard/governance/settings.py +365 -0
  117. commitguard/governance/simulation.py +618 -0
  118. commitguard/governance/workflow.py +734 -0
  119. commitguard/notifications/__init__.py +2 -0
  120. commitguard/notifications/channels/__init__.py +1 -0
  121. commitguard/notifications/channels/base.py +22 -0
  122. commitguard/notifications/channels/email.py +110 -0
  123. commitguard/notifications/channels/in_app.py +74 -0
  124. commitguard/notifications/channels/sink.py +58 -0
  125. commitguard/notifications/channels/webhook.py +233 -0
  126. commitguard/notifications/deduplication.py +57 -0
  127. commitguard/notifications/dispatcher.py +201 -0
  128. commitguard/notifications/models.py +439 -0
  129. commitguard/notifications/outbox.py +106 -0
  130. commitguard/notifications/preferences.py +224 -0
  131. commitguard/notifications/retry.py +282 -0
  132. commitguard/notifications/service.py +128 -0
  133. commitguard/notifications/settings.py +167 -0
  134. commitguard/notifications/templates.py +108 -0
  135. commitguard/observability/__init__.py +5 -0
  136. commitguard/observability/logging.py +161 -0
  137. commitguard/observability/metrics.py +105 -0
  138. commitguard/policies/__init__.py +6 -0
  139. commitguard/policies/defaults.py +48 -0
  140. commitguard/policies/evaluator.py +66 -0
  141. commitguard/policies/governance.py +498 -0
  142. commitguard/policies/loader.py +23 -0
  143. commitguard/policies/mandatory.py +52 -0
  144. commitguard/policies/model.py +46 -0
  145. commitguard/provenance/__init__.py +9 -0
  146. commitguard/provenance/author.py +146 -0
  147. commitguard/provenance/committer.py +16 -0
  148. commitguard/provenance/normalization.py +158 -0
  149. commitguard/provenance/signatures.py +34 -0
  150. commitguard/provenance/trailers.py +256 -0
  151. commitguard/research/__init__.py +26 -0
  152. commitguard/research/compare.py +231 -0
  153. commitguard/research/datasets.py +1484 -0
  154. commitguard/research/detection.py +183 -0
  155. commitguard/research/environment.py +185 -0
  156. commitguard/research/gitenv.py +108 -0
  157. commitguard/research/hooks.py +247 -0
  158. commitguard/research/metrics.py +85 -0
  159. commitguard/research/performance.py +194 -0
  160. commitguard/research/platform.py +288 -0
  161. commitguard/research/report.py +372 -0
  162. commitguard/research/repository.py +111 -0
  163. commitguard/research/reproduction.py +297 -0
  164. commitguard/research/results.py +94 -0
  165. commitguard/rules/__init__.py +11 -0
  166. commitguard/rules/data/ai-domains.yaml +51 -0
  167. commitguard/rules/data/ai-identities.yaml +131 -0
  168. commitguard/rules/data/bot-identities.yaml +53 -0
  169. commitguard/rules/data/patterns.yaml +52 -0
  170. commitguard/rules/loader.py +102 -0
  171. commitguard/rules/matcher.py +212 -0
  172. commitguard/rules/models.py +269 -0
  173. commitguard/security/__init__.py +5 -0
  174. commitguard/security/hashing.py +30 -0
  175. commitguard/security/rate_limit.py +33 -0
  176. commitguard/security/safe_yaml.py +69 -0
  177. commitguard/security/sanitization.py +85 -0
  178. commitguard/security/secrets.py +169 -0
  179. commitguard/security/validation.py +89 -0
  180. commitguard/services/__init__.py +15 -0
  181. commitguard/services/analysis.py +119 -0
  182. commitguard/services/audit.py +95 -0
  183. commitguard/services/ci.py +383 -0
  184. commitguard/services/enforcement.py +102 -0
  185. commitguard/services/hooks.py +254 -0
  186. commitguard/services/remediation.py +99 -0
  187. commitguard/services/reports.py +146 -0
  188. commitguard/services/scan.py +172 -0
  189. commitguard/utils/__init__.py +1 -0
  190. commitguard/utils/filesystem.py +72 -0
  191. commitguard/utils/platform.py +35 -0
  192. commitguard/utils/subprocess.py +84 -0
  193. commitguardian-0.1.0.dist-info/METADATA +694 -0
  194. commitguardian-0.1.0.dist-info/RECORD +197 -0
  195. commitguardian-0.1.0.dist-info/WHEEL +4 -0
  196. commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
  197. commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,478 @@
1
+ """Dashboard sign-in and sessions.
2
+
3
+ Authentication uses the GitHub App's own user authorization (the OAuth web
4
+ flow of a GitHub App). No passwords, personal access tokens or third-party
5
+ identity providers are involved.
6
+
7
+ ::
8
+
9
+ browser -> GET /api/v1/auth/login
10
+ state (random, bound to the browser by an HttpOnly cookie)
11
+ PKCE verifier (kept server side), return path (validated)
12
+ -> github.com/login/oauth/authorize -> user approves
13
+ -> GET /api/v1/auth/callback?code&state
14
+ state must match the cookie and an unexpired, unused record
15
+ code + verifier -> user access token (used immediately, never stored)
16
+ GET /user who signed in
17
+ GET /user/installations installations they can access
18
+ GET /user/installations/{id}/repositories repositories they can access
19
+ -> session row + the installation/repository lists, token discarded
20
+
21
+ Sessions
22
+ ========
23
+
24
+ * The session token is 256 bits from :mod:`secrets`, sent only in a
25
+ ``__Host-`` cookie (``HttpOnly``, ``Secure``, ``SameSite=Lax``, ``Path=/``).
26
+ The database stores its SHA-256 hash, so a copy of the database cannot be
27
+ replayed as a session.
28
+ * Sessions end after :data:`SESSION_LIFETIME` (the lifetime of a GitHub user
29
+ token) or :data:`SESSION_IDLE_TIMEOUT` without use, on sign-out, or when
30
+ revoked from the settings page. Changes to GitHub access take effect at the
31
+ next sign-in; role changes in CommitGuard take effect on the next request.
32
+ * The CSRF token is derived from the session token (``SHA-256("csrf" || token)``):
33
+ JavaScript receives it from ``GET /api/v1/auth/session`` and sends it in
34
+ ``X-CSRF-Token``; an attacker's page can read neither.
35
+ """
36
+
37
+ import base64
38
+ import hashlib
39
+ import hmac
40
+ import secrets
41
+ from collections.abc import Callable
42
+ from dataclasses import dataclass
43
+ from datetime import UTC, datetime, timedelta
44
+ from urllib.parse import urlsplit
45
+
46
+ from commitguard.audit.models import Actor, AuditEvent, AuditEventType
47
+ from commitguard.controlplane.access import Membership, Principal, Role
48
+ from commitguard.controlplane.errors import (
49
+ ControlPlaneError,
50
+ InputValidationError,
51
+ NotFoundError,
52
+ UpstreamUnavailableError,
53
+ )
54
+ from commitguard.controlplane.results import clean_text
55
+ from commitguard.controlplane.views import SessionView
56
+ from commitguard.github.client import GitHubClient
57
+ from commitguard.github.errors import GitHubAPIError, GitHubUnauthorizedError
58
+ from commitguard.github.identifiers import AccountType
59
+ from commitguard.github.storage import InstallationRecord, InstallationState, SqliteStateStore
60
+ from commitguard.observability.logging import get_logger
61
+ from commitguard.security.secrets import Secret, default_redactor
62
+ from commitguard.services.audit import AuditService
63
+
64
+ log = get_logger(__name__)
65
+
66
+ SESSION_LIFETIME = timedelta(hours=8)
67
+ SESSION_IDLE_TIMEOUT = timedelta(hours=2)
68
+ OAUTH_STATE_LIFETIME = timedelta(minutes=10)
69
+ LAST_SEEN_RESOLUTION = timedelta(minutes=1)
70
+ MAX_REPOSITORIES_PER_INSTALLATION = 10_000
71
+ MAX_USER_AGENT_CHARS = 200
72
+ MAX_RETURN_PATH_CHARS = 512
73
+ SESSION_TOKEN_BYTES = 32
74
+
75
+
76
+ class SessionExpiredError(ControlPlaneError):
77
+ code = "SESSION_EXPIRED"
78
+ status = 401
79
+
80
+ def __init__(self) -> None:
81
+ super().__init__("Your session has expired. Sign in again.")
82
+
83
+
84
+ class SignInError(ControlPlaneError):
85
+ code = "SIGN_IN_FAILED"
86
+ status = 400
87
+
88
+
89
+ def _hash(token: str) -> str:
90
+ return hashlib.sha256(token.encode("ascii")).hexdigest()
91
+
92
+
93
+ def _ts(value: datetime) -> float:
94
+ return value.timestamp()
95
+
96
+
97
+ def _dt(value: float) -> datetime:
98
+ return datetime.fromtimestamp(value, UTC)
99
+
100
+
101
+ def safe_return_path(value: str | None) -> str:
102
+ """A same-origin path to return to after sign-in (open redirects are impossible)."""
103
+ if not value or len(value) > MAX_RETURN_PATH_CHARS:
104
+ return "/dashboard"
105
+ if not value.startswith("/") or value.startswith("//") or "\\" in value:
106
+ return "/dashboard"
107
+ if any(ord(c) < 0x21 or ord(c) == 0x7F for c in value):
108
+ return "/dashboard"
109
+ parts = urlsplit(value)
110
+ if parts.scheme or parts.netloc or value.startswith("/api/"):
111
+ return "/dashboard"
112
+ return value
113
+
114
+
115
+ def csrf_token_for(session_token: str) -> str:
116
+ return hashlib.sha256(b"commitguard-csrf\x00" + session_token.encode("ascii")).hexdigest()
117
+
118
+
119
+ def pkce_challenge(verifier: str) -> str:
120
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
121
+ return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class SignInStart:
126
+ authorize_url: str
127
+ state: str # set as the browser-binding cookie
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class SignInComplete:
132
+ session_token: Secret
133
+ return_to: str
134
+ principal: Principal
135
+
136
+
137
+ class AuthService:
138
+ def __init__(
139
+ self,
140
+ store: SqliteStateStore,
141
+ client: GitHubClient,
142
+ audit: AuditService,
143
+ *,
144
+ client_id: str,
145
+ client_secret: Secret,
146
+ redirect_uri: str,
147
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
148
+ ) -> None:
149
+ self._store = store
150
+ self._client = client
151
+ self._audit = audit
152
+ self._client_id = client_id
153
+ self._client_secret = client_secret
154
+ self._redirect_uri = redirect_uri
155
+ self._now = now
156
+
157
+ # ------------------------------------------------------------------ #
158
+ # Sign-in
159
+ # ------------------------------------------------------------------ #
160
+ def begin_sign_in(self, return_to: str | None) -> SignInStart:
161
+ now = self._now()
162
+ state = secrets.token_urlsafe(32)
163
+ verifier = secrets.token_urlsafe(48)
164
+ with self._store.transaction() as db:
165
+ db.execute("DELETE FROM oauth_states WHERE expires_at < ?", (_ts(now),))
166
+ db.execute(
167
+ "INSERT INTO oauth_states (state_hash, verifier, return_to, expires_at) "
168
+ "VALUES (?, ?, ?, ?)",
169
+ (
170
+ _hash(state),
171
+ verifier,
172
+ safe_return_path(return_to),
173
+ _ts(now + OAUTH_STATE_LIFETIME),
174
+ ),
175
+ )
176
+ url = self._client.authorize_url(
177
+ client_id=self._client_id,
178
+ redirect_uri=self._redirect_uri,
179
+ state=state,
180
+ code_challenge=pkce_challenge(verifier),
181
+ )
182
+ return SignInStart(authorize_url=url, state=state)
183
+
184
+ def complete_sign_in(
185
+ self, *, code: str | None, state: str | None, cookie_state: str | None, user_agent: str
186
+ ) -> SignInComplete:
187
+ now = self._now()
188
+ if not code or not state or not cookie_state or len(code) > 512 or len(state) > 128:
189
+ raise SignInError("The sign-in request is incomplete. Start again.")
190
+ if not hmac.compare_digest(state.encode("utf-8"), cookie_state.encode("utf-8")):
191
+ raise SignInError("The sign-in request did not start in this browser. Start again.")
192
+ with self._store.transaction() as db:
193
+ row = db.execute(
194
+ "SELECT verifier, return_to, expires_at FROM oauth_states WHERE state_hash = ?",
195
+ (_hash(state),),
196
+ ).fetchone()
197
+ db.execute("DELETE FROM oauth_states WHERE state_hash = ?", (_hash(state),))
198
+ if row is None or row["expires_at"] < _ts(now):
199
+ raise SignInError("The sign-in request expired or was already used. Start again.")
200
+
201
+ try:
202
+ grant = self._client.exchange_oauth_code(
203
+ client_id=self._client_id,
204
+ client_secret=self._client_secret,
205
+ code=code,
206
+ redirect_uri=self._redirect_uri,
207
+ code_verifier=row["verifier"],
208
+ )
209
+ except GitHubUnauthorizedError:
210
+ raise SignInError("GitHub did not accept the sign-in. Start again.") from None
211
+ except GitHubAPIError:
212
+ raise UpstreamUnavailableError("GitHub could not complete the sign-in.") from None
213
+ try:
214
+ user, installations = self._github_access(grant.access_token)
215
+ finally:
216
+ default_redactor().forget(grant.access_token) # the token is not kept anywhere
217
+
218
+ token = secrets.token_urlsafe(SESSION_TOKEN_BYTES)
219
+ session_hash = _hash(token)
220
+ public_id = secrets.token_hex(8)
221
+ events: list[AuditEvent] = []
222
+ with self._store.transaction() as db:
223
+ db.execute(
224
+ "INSERT INTO users (user_id, login, created_at, last_login_at) VALUES (?, ?, ?, ?) "
225
+ "ON CONFLICT (user_id) DO UPDATE SET login = excluded.login, "
226
+ "last_login_at = excluded.last_login_at",
227
+ (user[0], user[1], _ts(now), _ts(now)),
228
+ )
229
+ db.execute(
230
+ "INSERT INTO sessions (session_hash, public_id, user_id, created_at, "
231
+ "authenticated_at, last_seen_at, expires_at, user_agent) "
232
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
233
+ (
234
+ session_hash,
235
+ public_id,
236
+ user[0],
237
+ _ts(now),
238
+ _ts(now),
239
+ _ts(now),
240
+ _ts(now + SESSION_LIFETIME),
241
+ clean_text(user_agent or "unknown", MAX_USER_AGENT_CHARS),
242
+ ),
243
+ )
244
+ for installation_id, repository_ids in installations.items():
245
+ db.execute(
246
+ "INSERT INTO session_installations (session_hash, installation_id) "
247
+ "VALUES (?, ?)",
248
+ (session_hash, installation_id),
249
+ )
250
+ db.executemany(
251
+ "INSERT OR IGNORE INTO session_repositories (session_hash, installation_id, "
252
+ "repository_id) VALUES (?, ?, ?)",
253
+ [(session_hash, installation_id, rid) for rid in repository_ids],
254
+ )
255
+ principal = self._principal(session_hash)
256
+ if principal is None: # pragma: no cover - the rows were written above
257
+ raise SignInError("The session could not be created.")
258
+ actor = Actor.user(principal.user_id, principal.login)
259
+ for account_id in principal.memberships:
260
+ events.append(
261
+ self._audit.build(
262
+ AuditEventType.USER_SIGNED_IN,
263
+ actor=actor,
264
+ account_id=account_id,
265
+ session=public_id,
266
+ )
267
+ )
268
+ with self._store.transaction() as db:
269
+ events = [self._store.insert_audit_event(db, e) for e in events]
270
+ for event in events:
271
+ self._audit.log_stored(event)
272
+ return SignInComplete(Secret(token), row["return_to"], principal)
273
+
274
+ def _github_access(self, user_token: Secret) -> tuple[tuple[int, str], dict[int, list[int]]]:
275
+ try:
276
+ user = self._client.get_authenticated_user(user_token)
277
+ installations = self._client.list_user_installations(user_token)
278
+ access: dict[int, list[int]] = {}
279
+ for info in installations:
280
+ record = self._store.get_installation(info.id)
281
+ if record is None:
282
+ # GitHub vouches for this installation of the App; remember its account.
283
+ now = self._now()
284
+ self._store.upsert_installation(
285
+ InstallationRecord(
286
+ installation_id=info.id,
287
+ account_id=info.account.id,
288
+ account_login=info.account.login,
289
+ account_type=AccountType(info.account.type),
290
+ repository_selection=info.repository_selection,
291
+ state=InstallationState.SUSPENDED
292
+ if info.suspended_at
293
+ else InstallationState.ACTIVE,
294
+ permissions=info.permissions,
295
+ created_at=now,
296
+ updated_at=now,
297
+ )
298
+ )
299
+ elif record.account_id != info.account.id:
300
+ log.warning("installation_account_mismatch", installation_id=info.id)
301
+ continue
302
+ elif record.state is InstallationState.DELETED:
303
+ continue
304
+ ids = self._client.list_user_installation_repository_ids(user_token, info.id)
305
+ access[info.id] = ids[:MAX_REPOSITORIES_PER_INSTALLATION]
306
+ except GitHubUnauthorizedError:
307
+ raise SignInError("GitHub did not accept the sign-in. Start again.") from None
308
+ except GitHubAPIError:
309
+ raise UpstreamUnavailableError("GitHub could not list your access.") from None
310
+ return (user.id, user.login), access
311
+
312
+ # ------------------------------------------------------------------ #
313
+ # Sessions
314
+ # ------------------------------------------------------------------ #
315
+ def authenticate(self, session_token: str | None) -> Principal | None:
316
+ """The principal for a session cookie, or None. Raises when it expired."""
317
+ if not session_token or len(session_token) > 128 or not session_token.isascii():
318
+ return None
319
+ now = self._now()
320
+ session_hash = _hash(session_token)
321
+ rows = self._store.query(
322
+ "SELECT expires_at, last_seen_at FROM sessions WHERE session_hash = ?", (session_hash,)
323
+ )
324
+ if not rows:
325
+ return None
326
+ row = rows[0]
327
+ if row["expires_at"] <= _ts(now) or row[
328
+ "last_seen_at"
329
+ ] + SESSION_IDLE_TIMEOUT.total_seconds() <= _ts(now):
330
+ with self._store.transaction() as db:
331
+ db.execute("DELETE FROM sessions WHERE session_hash = ?", (session_hash,))
332
+ raise SessionExpiredError()
333
+ if _ts(now) - row["last_seen_at"] >= LAST_SEEN_RESOLUTION.total_seconds():
334
+ with self._store.transaction() as db:
335
+ db.execute(
336
+ "UPDATE sessions SET last_seen_at = ? WHERE session_hash = ?",
337
+ (_ts(now), session_hash),
338
+ )
339
+ return self._principal(session_hash)
340
+
341
+ def _principal(self, session_hash: str) -> Principal | None:
342
+ rows = self._store.query(
343
+ "SELECT s.public_id, s.user_id, s.authenticated_at, s.expires_at, u.login "
344
+ "FROM sessions s JOIN users u ON u.user_id = s.user_id WHERE s.session_hash = ?",
345
+ (session_hash,),
346
+ )
347
+ if not rows:
348
+ return None
349
+ session = rows[0]
350
+ user_id = int(session["user_id"])
351
+ installations = {
352
+ int(r["installation_id"]): int(r["account_id"])
353
+ for r in self._store.query(
354
+ "SELECT si.installation_id, i.account_id FROM session_installations si "
355
+ "JOIN installations i ON i.installation_id = si.installation_id "
356
+ "WHERE si.session_hash = ? AND i.state != 'deleted'",
357
+ (session_hash,),
358
+ )
359
+ }
360
+ memberships: dict[int, Membership] = {}
361
+ accounts = {
362
+ int(r["account_id"]): r
363
+ for r in self._store.query(
364
+ "SELECT account_id, account_login, account_type FROM installations "
365
+ "WHERE installation_id IN (SELECT installation_id FROM session_installations "
366
+ "WHERE session_hash = ?) AND state != 'deleted'",
367
+ (session_hash,),
368
+ )
369
+ }
370
+ for row in self._store.query(
371
+ "SELECT account_id, role FROM memberships WHERE user_id = ?", (user_id,)
372
+ ):
373
+ account = accounts.get(int(row["account_id"]))
374
+ if account is None:
375
+ continue # GitHub did not report access to this account at sign-in
376
+ memberships[int(row["account_id"])] = Membership(
377
+ account_id=int(row["account_id"]),
378
+ account_login=account["account_login"],
379
+ account_type=account["account_type"],
380
+ role=Role(row["role"]),
381
+ )
382
+ for account_id, account in accounts.items():
383
+ if account["account_type"] == AccountType.USER.value and account_id == user_id:
384
+ memberships[account_id] = Membership(
385
+ account_id=account_id,
386
+ account_login=account["account_login"],
387
+ account_type=account["account_type"],
388
+ role=Role.OWNER,
389
+ implicit=True,
390
+ )
391
+ return Principal(
392
+ user_id=user_id,
393
+ login=session["login"],
394
+ session_hash=session_hash,
395
+ session_public_id=session["public_id"],
396
+ authenticated_at=_dt(session["authenticated_at"]),
397
+ expires_at=_dt(session["expires_at"]),
398
+ memberships=memberships,
399
+ installations={i: a for i, a in installations.items() if a in memberships},
400
+ )
401
+
402
+ @staticmethod
403
+ def verify_csrf(session_token: str | None, header: str | None) -> bool:
404
+ if not session_token or not header or len(header) > 128:
405
+ return False
406
+ return hmac.compare_digest(csrf_token_for(session_token).encode(), header.encode())
407
+
408
+ def sign_out(self, principal: Principal) -> None:
409
+ with self._store.transaction() as db:
410
+ db.execute("DELETE FROM sessions WHERE session_hash = ?", (principal.session_hash,))
411
+ events = [
412
+ self._store.insert_audit_event(
413
+ db,
414
+ self._audit.build(
415
+ AuditEventType.USER_SIGNED_OUT,
416
+ actor=Actor.user(principal.user_id, principal.login),
417
+ account_id=account_id,
418
+ session=principal.session_public_id,
419
+ ),
420
+ )
421
+ for account_id in principal.memberships
422
+ ]
423
+ for event in events:
424
+ self._audit.log_stored(event)
425
+
426
+ def list_sessions(self, principal: Principal) -> list[SessionView]:
427
+ now = _ts(self._now())
428
+ rows = self._store.query(
429
+ "SELECT public_id, created_at, last_seen_at, expires_at, user_agent FROM sessions "
430
+ "WHERE user_id = ? AND expires_at > ? ORDER BY last_seen_at DESC LIMIT 50",
431
+ (principal.user_id, now),
432
+ )
433
+ return [
434
+ SessionView(
435
+ id=r["public_id"],
436
+ created_at=_dt(r["created_at"]),
437
+ last_seen_at=_dt(r["last_seen_at"]),
438
+ expires_at=_dt(r["expires_at"]),
439
+ user_agent=r["user_agent"],
440
+ current=r["public_id"] == principal.session_public_id,
441
+ )
442
+ for r in rows
443
+ ]
444
+
445
+ def revoke_session(self, principal: Principal, public_id: str) -> None:
446
+ """Revoke one of the caller's own sessions (another user's session is 'not found')."""
447
+ if not public_id.isascii() or not public_id.isalnum() or len(public_id) > 32:
448
+ raise InputValidationError("invalid session ID")
449
+ with self._store.transaction() as db:
450
+ cursor = db.execute(
451
+ "DELETE FROM sessions WHERE public_id = ? AND user_id = ?",
452
+ (public_id, principal.user_id),
453
+ )
454
+ if cursor.rowcount != 1:
455
+ raise NotFoundError()
456
+ events = [
457
+ self._store.insert_audit_event(
458
+ db,
459
+ self._audit.build(
460
+ AuditEventType.SESSION_REVOKED,
461
+ actor=Actor.user(principal.user_id, principal.login),
462
+ account_id=account_id,
463
+ session=public_id,
464
+ ),
465
+ )
466
+ for account_id in principal.memberships
467
+ ]
468
+ for event in events:
469
+ self._audit.log_stored(event)
470
+
471
+ def purge_expired(self) -> int:
472
+ now = _ts(self._now())
473
+ idle = now - SESSION_IDLE_TIMEOUT.total_seconds()
474
+ with self._store.transaction() as db:
475
+ db.execute("DELETE FROM oauth_states WHERE expires_at < ?", (now,))
476
+ return db.execute(
477
+ "DELETE FROM sessions WHERE expires_at <= ? OR last_seen_at <= ?", (now, idle)
478
+ ).rowcount
@@ -0,0 +1,219 @@
1
+ """Organisation members and their roles.
2
+
3
+ Roles are granted by an owner in the dashboard or by the operator with
4
+ ``commitguard dashboard members grant``. A grant names a GitHub user by its
5
+ immutable numeric ID, never by login: logins can be renamed and re-registered.
6
+
7
+ Rules enforced here (not in the browser):
8
+
9
+ * an organisation always keeps at least one explicit owner once it has one,
10
+ so the last owner cannot be demoted or removed;
11
+ * owners cannot change their own role (another owner must do it);
12
+ * the owner of a personal installation is implicit and cannot be edited.
13
+ """
14
+
15
+ from collections.abc import Callable
16
+ from datetime import UTC, datetime
17
+
18
+ from commitguard.audit.models import Actor, AuditEventType
19
+ from commitguard.controlplane.access import Role
20
+ from commitguard.controlplane.errors import (
21
+ ConflictError,
22
+ InputValidationError,
23
+ NotFoundError,
24
+ PermissionDeniedError,
25
+ )
26
+ from commitguard.controlplane.views import MemberView
27
+ from commitguard.github.identifiers import MAX_GITHUB_ID, AccountType, validate_login
28
+ from commitguard.github.storage import SqliteStateStore
29
+ from commitguard.services.audit import AuditService
30
+
31
+
32
+ def _dt(value: float) -> datetime:
33
+ return datetime.fromtimestamp(value, UTC)
34
+
35
+
36
+ class MembershipService:
37
+ def __init__(
38
+ self,
39
+ store: SqliteStateStore,
40
+ audit: AuditService,
41
+ *,
42
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
43
+ ) -> None:
44
+ self._store = store
45
+ self._audit = audit
46
+ self._now = now
47
+
48
+ def account(self, account_id: int) -> tuple[int, str, str] | None:
49
+ rows = self._store.query(
50
+ "SELECT account_id, account_login, account_type FROM installations "
51
+ "WHERE account_id = ? ORDER BY updated_at DESC LIMIT 1",
52
+ (int(account_id),),
53
+ )
54
+ if not rows:
55
+ return None
56
+ return int(rows[0]["account_id"]), rows[0]["account_login"], rows[0]["account_type"]
57
+
58
+ def account_by_login(self, login: str) -> tuple[int, str, str] | None:
59
+ validate_login(login)
60
+ rows = self._store.query(
61
+ "SELECT account_id, account_login, account_type FROM installations "
62
+ "WHERE account_login = ? COLLATE NOCASE ORDER BY updated_at DESC LIMIT 1",
63
+ (login,),
64
+ )
65
+ if not rows:
66
+ return None
67
+ return int(rows[0]["account_id"]), rows[0]["account_login"], rows[0]["account_type"]
68
+
69
+ def list_members(
70
+ self, account_id: int, *, offset: int = 0, limit: int = 100
71
+ ) -> list[MemberView]:
72
+ members: list[MemberView] = []
73
+ account = self.account(account_id)
74
+ if account and account[2] == AccountType.USER.value and offset == 0:
75
+ members.append(
76
+ MemberView(
77
+ user_id=account_id,
78
+ login=account[1],
79
+ role=Role.OWNER.value,
80
+ granted_by="GitHub account owner",
81
+ created_at=_dt(0),
82
+ updated_at=_dt(0),
83
+ implicit=True,
84
+ )
85
+ )
86
+ rows = self._store.query(
87
+ "SELECT m.user_id, u.login, m.role, m.granted_by, m.created_at, m.updated_at "
88
+ "FROM memberships m LEFT JOIN users u ON u.user_id = m.user_id "
89
+ "WHERE m.account_id = ? ORDER BY m.created_at, m.user_id LIMIT ? OFFSET ?",
90
+ (int(account_id), int(limit), int(offset)),
91
+ )
92
+ members.extend(
93
+ MemberView(
94
+ user_id=int(r["user_id"]),
95
+ login=r["login"],
96
+ role=r["role"],
97
+ granted_by=r["granted_by"],
98
+ created_at=_dt(r["created_at"]),
99
+ updated_at=_dt(r["updated_at"]),
100
+ implicit=False,
101
+ )
102
+ for r in rows
103
+ )
104
+ return members
105
+
106
+ def grant(
107
+ self,
108
+ *,
109
+ account_id: int,
110
+ user_id: int,
111
+ role: Role,
112
+ actor: Actor,
113
+ login: str | None = None,
114
+ ) -> MemberView:
115
+ if not 0 < user_id < MAX_GITHUB_ID:
116
+ raise InputValidationError("invalid GitHub user ID", field="user_id")
117
+ if login is not None:
118
+ try:
119
+ validate_login(login)
120
+ except ValueError:
121
+ raise InputValidationError("invalid GitHub login", field="login") from None
122
+ account = self.account(account_id)
123
+ if account is None:
124
+ raise NotFoundError("No GitHub App installation is known for this account.")
125
+ if account[2] == AccountType.USER.value and user_id == account_id:
126
+ raise ConflictError("The owner of a personal account is always its owner.")
127
+ if actor.id is not None and actor.id == user_id:
128
+ raise PermissionDeniedError("You cannot change your own role.")
129
+ now = self._now().timestamp()
130
+ granted_by = actor.login or actor.type.value
131
+ with self._store.transaction() as db:
132
+ existing = db.execute(
133
+ "SELECT role FROM memberships WHERE account_id = ? AND user_id = ?",
134
+ (int(account_id), int(user_id)),
135
+ ).fetchone()
136
+ if (
137
+ existing is not None
138
+ and existing["role"] == Role.OWNER.value
139
+ and role is not Role.OWNER
140
+ ):
141
+ self._require_other_owner(db, account_id, user_id)
142
+ if login is not None:
143
+ db.execute(
144
+ "INSERT INTO users (user_id, login, created_at) VALUES (?, ?, ?) "
145
+ "ON CONFLICT (user_id) DO NOTHING",
146
+ (int(user_id), login, now),
147
+ )
148
+ db.execute(
149
+ "INSERT INTO memberships (account_id, user_id, role, granted_by, created_at, "
150
+ "updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (account_id, user_id) DO UPDATE "
151
+ "SET role = excluded.role, granted_by = excluded.granted_by, "
152
+ "updated_at = excluded.updated_at",
153
+ (int(account_id), int(user_id), role.value, granted_by, now, now),
154
+ )
155
+ event = self._store.insert_audit_event(
156
+ db,
157
+ self._audit.build(
158
+ AuditEventType.MEMBER_ROLE_CHANGED
159
+ if existing is not None
160
+ else AuditEventType.MEMBER_ROLE_GRANTED,
161
+ actor=actor,
162
+ account_id=account_id,
163
+ member=int(user_id),
164
+ old_role=existing["role"] if existing is not None else None,
165
+ new_role=role.value,
166
+ ),
167
+ )
168
+ self._audit.log_stored(event)
169
+ members = [m for m in self.list_members(account_id, limit=10_000) if m.user_id == user_id]
170
+ return members[-1]
171
+
172
+ def remove(self, *, account_id: int, user_id: int, actor: Actor) -> None:
173
+ if actor.id is not None and actor.id == user_id:
174
+ raise PermissionDeniedError("You cannot remove your own membership.")
175
+ with self._store.transaction() as db:
176
+ existing = db.execute(
177
+ "SELECT role FROM memberships WHERE account_id = ? AND user_id = ?",
178
+ (int(account_id), int(user_id)),
179
+ ).fetchone()
180
+ if existing is None:
181
+ raise NotFoundError()
182
+ if existing["role"] == Role.OWNER.value:
183
+ self._require_other_owner(db, account_id, user_id)
184
+ db.execute(
185
+ "DELETE FROM memberships WHERE account_id = ? AND user_id = ?",
186
+ (int(account_id), int(user_id)),
187
+ )
188
+ # Access is re-read on every request, so the organization is gone from the
189
+ # member's next request. With no membership left, their sessions end too.
190
+ remaining = db.execute(
191
+ "SELECT COUNT(*) AS n FROM memberships WHERE user_id = ?", (int(user_id),)
192
+ ).fetchone()["n"]
193
+ sessions_ended = 0
194
+ if int(remaining) == 0:
195
+ sessions_ended = db.execute(
196
+ "DELETE FROM sessions WHERE user_id = ?", (int(user_id),)
197
+ ).rowcount
198
+ event = self._store.insert_audit_event(
199
+ db,
200
+ self._audit.build(
201
+ AuditEventType.MEMBER_REMOVED,
202
+ actor=actor,
203
+ account_id=account_id,
204
+ member=int(user_id),
205
+ old_role=existing["role"],
206
+ sessions_ended=sessions_ended,
207
+ ),
208
+ )
209
+ self._audit.log_stored(event)
210
+
211
+ @staticmethod
212
+ def _require_other_owner(db, account_id: int, user_id: int) -> None: # type: ignore[no-untyped-def]
213
+ row = db.execute(
214
+ "SELECT COUNT(*) AS owners FROM memberships WHERE account_id = ? AND role = 'owner' "
215
+ "AND user_id != ?",
216
+ (int(account_id), int(user_id)),
217
+ ).fetchone()
218
+ if int(row["owners"]) == 0:
219
+ raise ConflictError("An organization must keep at least one owner.")