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.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- 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.")
|