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,105 @@
1
+ """Configuration schema.
2
+
3
+ Every model forbids extra keys and uses strict scalar types, so that e.g.
4
+ ``enabled: "no"`` or a misspelled ``acton: allow`` is an error instead of a
5
+ silent change in security behaviour.
6
+ """
7
+
8
+ from typing import Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, field_validator
11
+
12
+ from commitguard.core.decision import Action
13
+ from commitguard.policies.defaults import KNOWN_POLICY_IDS
14
+
15
+
16
+ class PolicyOverride(BaseModel):
17
+ """Repository override for one built-in policy. Unset fields keep defaults."""
18
+
19
+ model_config = ConfigDict(frozen=True, extra="forbid")
20
+
21
+ enabled: StrictBool | None = None
22
+ action: Action | None = None
23
+
24
+ @field_validator("enabled", "action", mode="before")
25
+ @classmethod
26
+ def _reject_explicit_null(cls, value: object) -> object:
27
+ # ``action:`` with no value in YAML is null; treat it as a mistake.
28
+ if value is None:
29
+ raise ValueError("must not be null; remove the key to use the default")
30
+ return value
31
+
32
+
33
+ class EnforcementOverride(BaseModel):
34
+ """Which Git hooks enforce policy. Unset fields keep the value from lower layers.
35
+
36
+ Disabling a hook never changes *policies*; it only stops that hook from
37
+ running the analysis, and ``commitguard doctor`` reports enforcement as
38
+ incomplete.
39
+ """
40
+
41
+ model_config = ConfigDict(frozen=True, extra="forbid")
42
+
43
+ pre_commit: StrictBool | None = None
44
+ commit_msg: StrictBool | None = None
45
+ pre_push: StrictBool | None = None
46
+ max_push_commits: StrictInt | None = Field(default=None, ge=1, le=1_000_000)
47
+
48
+ @field_validator("pre_commit", "commit_msg", "pre_push", "max_push_commits", mode="before")
49
+ @classmethod
50
+ def _reject_explicit_null(cls, value: object) -> object:
51
+ if value is None:
52
+ raise ValueError("must not be null; remove the key to use the default")
53
+ return value
54
+
55
+
56
+ class RemediationOverride(BaseModel):
57
+ """What CommitGuard may do about a violation, beyond reporting it.
58
+
59
+ ``auto_remove`` lets the ``commit-msg`` hook delete the offending lines from
60
+ the pending message instead of refusing the commit. It only ever applies
61
+ when *every* blocking finding is a message line: attribution carried by the
62
+ author or committer identity cannot be fixed by editing text, and still
63
+ blocks. The stripped message is re-analysed, and the commit proceeds only if
64
+ it is then clean.
65
+
66
+ It is off by default, because it edits what the developer wrote.
67
+ """
68
+
69
+ model_config = ConfigDict(frozen=True, extra="forbid")
70
+
71
+ auto_remove: StrictBool | None = None
72
+
73
+ @field_validator("auto_remove", mode="before")
74
+ @classmethod
75
+ def _reject_explicit_null(cls, value: object) -> object:
76
+ if value is None:
77
+ raise ValueError("must not be null; remove the key to use the default")
78
+ return value
79
+
80
+
81
+ class CommitGuardConfig(BaseModel):
82
+ """Top-level ``.commitguard.yaml`` document."""
83
+
84
+ model_config = ConfigDict(frozen=True, extra="forbid")
85
+
86
+ version: Literal[1]
87
+ policies: dict[str, PolicyOverride] = {}
88
+ enforcement: EnforcementOverride = EnforcementOverride()
89
+ remediation: RemediationOverride = RemediationOverride()
90
+
91
+ @field_validator("version", mode="before")
92
+ @classmethod
93
+ def _strict_version(cls, value: object) -> object:
94
+ if type(value) is not int:
95
+ raise ValueError("version must be the integer 1")
96
+ return value
97
+
98
+ @field_validator("policies")
99
+ @classmethod
100
+ def _known_policies(cls, value: dict[str, PolicyOverride]) -> dict[str, PolicyOverride]:
101
+ unknown = sorted(set(value) - KNOWN_POLICY_IDS)
102
+ if unknown:
103
+ known = ", ".join(sorted(KNOWN_POLICY_IDS))
104
+ raise ValueError(f"unknown policy id(s): {', '.join(unknown)} (known: {known})")
105
+ return value
@@ -0,0 +1,183 @@
1
+ """Policy sources: where the policy that evaluates a change comes from.
2
+
3
+ The source of a policy is a security decision. A pull request that edits
4
+ ``.commitguard.yaml`` to ``action: allow`` must not be evaluated with the policy
5
+ it introduces, otherwise it could approve itself.
6
+
7
+ ================= ======================================= ======================
8
+ Kind Configuration read from Used by
9
+ ================= ======================================= ======================
10
+ ``working_tree`` global config + work tree + ``--config`` local scan/check/hooks
11
+ ``revision`` the tree of a *trusted* commit (never CI (PR base, push
12
+ the work tree); no global config ``before``, default
13
+ branch)
14
+ ``builtin`` built-in secure defaults only CI when no trusted
15
+ commit exists
16
+ ================= ======================================= ======================
17
+
18
+ On top of any source, a central service can apply a :class:`MandatoryPolicy`
19
+ (e.g. the operator of the GitHub App, later an organisation). It is a floor,
20
+ not a layer: repository configuration cannot weaken it (see
21
+ :mod:`commitguard.policies.mandatory`). The intended future hierarchy is::
22
+
23
+ global mandatory policy -> organisation mandatory policy
24
+ -> trusted repository configuration -> (local developer configuration)
25
+
26
+ where every mandatory level can only tighten what the levels below produce.
27
+ """
28
+
29
+ from enum import StrEnum
30
+ from pathlib import Path, PurePosixPath
31
+
32
+ from pydantic import BaseModel, ConfigDict, field_validator
33
+
34
+ from commitguard.config.defaults import CONFIG_FILENAMES, MAX_CONFIG_BYTES
35
+ from commitguard.config.loader import (
36
+ ConfigLayer,
37
+ ConfigSource,
38
+ LoadedConfig,
39
+ load_config,
40
+ load_effective_config,
41
+ parse_config,
42
+ )
43
+ from commitguard.config.schema import CommitGuardConfig
44
+ from commitguard.exceptions.base import UnsafeInputError
45
+ from commitguard.exceptions.configuration import ConfigurationError
46
+ from commitguard.git.repository import Repository
47
+ from commitguard.policies.mandatory import validate_mandatory_config
48
+ from commitguard.security.hashing import sha256_hex
49
+ from commitguard.security.validation import validate_git_sha, validate_repository_path
50
+
51
+
52
+ class PolicySourceKind(StrEnum):
53
+ WORKING_TREE = "working_tree"
54
+ REVISION = "revision"
55
+ BUILTIN = "builtin"
56
+
57
+
58
+ class PolicySource(BaseModel):
59
+ """A description of where policy is loaded from, shown in every report."""
60
+
61
+ model_config = ConfigDict(frozen=True, extra="forbid")
62
+
63
+ kind: PolicySourceKind
64
+ revision: str | None = None
65
+ description: str
66
+ config_path: str | None = None # repository-relative; None = .commitguard.yaml/.yml
67
+
68
+ @field_validator("revision")
69
+ @classmethod
70
+ def _valid_revision(cls, value: str | None) -> str | None:
71
+ return None if value is None else validate_git_sha(value)
72
+
73
+ @field_validator("config_path")
74
+ @classmethod
75
+ def _valid_path(cls, value: str | None) -> str | None:
76
+ return None if value is None else validate_repository_path(value)
77
+
78
+ def __str__(self) -> str:
79
+ if self.kind is PolicySourceKind.REVISION and self.revision:
80
+ return f"{self.description} ({self.revision[:12]})"
81
+ return self.description
82
+
83
+
84
+ class MandatoryPolicy(BaseModel):
85
+ """A policy floor applied after all configuration layers."""
86
+
87
+ model_config = ConfigDict(frozen=True, extra="forbid")
88
+
89
+ config: CommitGuardConfig
90
+ description: str
91
+ fingerprint: str
92
+
93
+
94
+ def load_mandatory_policy(path: Path, *, description: str | None = None) -> MandatoryPolicy:
95
+ """Load and validate a mandatory policy file (same schema as ``.commitguard.yaml``)."""
96
+ config = load_config(path)
97
+ try:
98
+ validate_mandatory_config(config)
99
+ except ValueError as exc:
100
+ raise ConfigurationError(str(exc), path=path) from None
101
+ canonical = config.model_dump_json(exclude_unset=True)
102
+ return MandatoryPolicy(
103
+ config=config,
104
+ description=description or path.name,
105
+ fingerprint=sha256_hex(canonical.encode("utf-8")),
106
+ )
107
+
108
+
109
+ def load_config_at_revision(
110
+ repository: Repository, revision: str, *, config_path: str | None = None
111
+ ) -> LoadedConfig:
112
+ """Built-in defaults + the repository configuration stored in ``revision``.
113
+
114
+ Nothing is read from the work tree or the user's global configuration. With
115
+ ``config_path`` the file must exist at that revision; otherwise the usual
116
+ names are looked up and a missing file means built-in defaults.
117
+ """
118
+ layers: list[tuple[ConfigSource, CommitGuardConfig]] = [
119
+ (ConfigSource(layer=ConfigLayer.BUILTIN), CommitGuardConfig(version=1))
120
+ ]
121
+ candidates = [config_path] if config_path else list(CONFIG_FILENAMES)
122
+ found: list[tuple[str, bytes]] = []
123
+ for name in candidates:
124
+ try:
125
+ data = repository.read_blob_at(revision, name, max_bytes=MAX_CONFIG_BYTES)
126
+ except UnsafeInputError as exc:
127
+ raise ConfigurationError(f"{exc} (trusted revision {revision[:12]})") from exc
128
+ if data is not None:
129
+ found.append((name, data))
130
+ if config_path and not found:
131
+ raise ConfigurationError(
132
+ f"configuration file {config_path} does not exist at trusted revision {revision[:12]}"
133
+ )
134
+ if len(found) > 1:
135
+ raise ConfigurationError(
136
+ f"multiple configuration files at {revision[:12]}: "
137
+ + ", ".join(name for name, _ in found)
138
+ )
139
+ if found:
140
+ name, data = found[0]
141
+ display = Path(PurePosixPath(name))
142
+ try:
143
+ text = data.decode("utf-8")
144
+ except UnicodeDecodeError as exc:
145
+ raise ConfigurationError(
146
+ f"not valid UTF-8 text (trusted revision {revision[:12]})", path=display
147
+ ) from exc
148
+ config = parse_config(text, path=display)
149
+ source = ConfigSource(layer=ConfigLayer.REPOSITORY, path=display, revision=revision)
150
+ layers.append((source, config))
151
+ return LoadedConfig(layers=tuple(layers))
152
+
153
+
154
+ def load_policy_source(repository: Repository, source: PolicySource) -> LoadedConfig:
155
+ if source.kind is PolicySourceKind.REVISION:
156
+ if source.revision is None:
157
+ raise ConfigurationError("revision policy source without a revision")
158
+ return load_config_at_revision(repository, source.revision, config_path=source.config_path)
159
+ if source.kind is PolicySourceKind.BUILTIN:
160
+ if source.config_path:
161
+ raise ConfigurationError(
162
+ f"configuration file {source.config_path} requested but no trusted revision exists"
163
+ )
164
+ return LoadedConfig(
165
+ layers=((ConfigSource(layer=ConfigLayer.BUILTIN), CommitGuardConfig(version=1)),)
166
+ )
167
+ explicit = repository.root / source.config_path if source.config_path else None
168
+ return load_effective_config(repository.root, explicit_path=explicit)
169
+
170
+
171
+ def config_differs(repository: Repository, trusted: str, head: str) -> list[str]:
172
+ """Configuration file names whose content differs between two commits."""
173
+ changed = []
174
+ for name in CONFIG_FILENAMES:
175
+ try:
176
+ before = repository.read_blob_at(trusted, name, max_bytes=MAX_CONFIG_BYTES)
177
+ after = repository.read_blob_at(head, name, max_bytes=MAX_CONFIG_BYTES)
178
+ except UnsafeInputError:
179
+ changed.append(name) # e.g. replaced by a symlink or oversized: report it
180
+ continue
181
+ if before != after:
182
+ changed.append(name)
183
+ return changed
@@ -0,0 +1,24 @@
1
+ """The CommitGuard control plane: what the dashboard API exposes.
2
+
3
+ ::
4
+
5
+ CommitGuard core (detection, policy) -> ScanService -> ScanResult
6
+ | |
7
+ | ScanResultRecorder (findings, violation lifecycle)
8
+ v v
9
+ OrganizationPolicyService ---- mandatory floor ----> state database
10
+ ^ ^
11
+ | |
12
+ dashboard API routes -> authentication -> authorization -> query/command services
13
+
14
+ Nothing here detects attribution or evaluates policy. Scans are produced by
15
+ the same :class:`~commitguard.services.scan.ScanService` the GitHub Action and
16
+ the GitHub App use; this package stores their results, tracks whether each
17
+ violation is still present, versions organisation policy, and answers
18
+ questions about that data for one authorised principal at a time.
19
+
20
+ Tenant isolation is structural: every read takes an
21
+ :class:`~commitguard.controlplane.access.AccessScope` built from the signed-in
22
+ session, and every query filters by the installations and repositories in that
23
+ scope.
24
+ """
@@ -0,0 +1,231 @@
1
+ """Roles, permissions and the access scope of a signed-in user.
2
+
3
+ Tenant model
4
+ ============
5
+
6
+ * A **tenant** (organisation) is a GitHub account - an organisation or a user -
7
+ identified by its immutable numeric account ID. It owns one GitHub App
8
+ installation (GitHub allows one per account), whose repositories, scans,
9
+ violations and audit events belong to it.
10
+ * A **member** is a GitHub user with a CommitGuard role in that tenant. Roles
11
+ are granted in CommitGuard (``commitguard dashboard members grant`` or by an
12
+ owner in the dashboard); GitHub does not grant them.
13
+ * Access needs both: a role in CommitGuard *and* access in GitHub. At sign-in
14
+ GitHub reports which installations and repositories the user can see
15
+ (``GET /user/installations``, ``GET /user/installations/{id}/repositories``);
16
+ CommitGuard stores that list with the session and never shows a repository
17
+ the user could not open on GitHub, whatever their CommitGuard role.
18
+
19
+ Roles
20
+ =====
21
+
22
+ ================== =========================================================
23
+ Role Adds
24
+ ================== =========================================================
25
+ ``viewer`` read repositories, scans, violations, policies, rules,
26
+ exceptions, the organization and its security posture;
27
+ receive in-app notifications about them
28
+ ``security_manager`` acknowledge violations, request re-scans, read the audit log,
29
+ request policy exceptions
30
+ ``admin`` change, publish, approve and roll back policy, manage
31
+ repository groups, onboarding, rollouts, scan schedules,
32
+ organization rules and settings, approve and revoke
33
+ exceptions, stop/resume monitoring a repository, refresh
34
+ enforcement status, sync installation repositories, list
35
+ members, manage organisation notification settings and webhooks
36
+ ``owner`` grant, change and remove member roles; emergency policy
37
+ publication (bypassing approval, always audited)
38
+ ================== =========================================================
39
+
40
+ Each role includes every permission of the roles above it. The owner of a
41
+ personal (user-account) installation is always its ``owner``.
42
+
43
+ Authorization is by permission, never by role name: routes and services check
44
+ a :class:`Permission`, and the role table below is the only place that maps
45
+ roles to permissions. There is no separate ``member`` role: ``viewer`` is the
46
+ least-privileged member. Separation of duties (a policy change approved by
47
+ someone other than its author) is enforced by the policy workflow, not by roles.
48
+ """
49
+
50
+ import json
51
+ from collections.abc import Iterable, Mapping
52
+ from dataclasses import dataclass, field
53
+ from datetime import datetime
54
+ from enum import StrEnum
55
+
56
+
57
+ class Permission(StrEnum):
58
+ REPOSITORIES_READ = "repositories:read"
59
+ REPOSITORIES_MANAGE = "repositories:manage"
60
+ SCANS_READ = "scans:read"
61
+ SCANS_TRIGGER = "scans:trigger"
62
+ VIOLATIONS_READ = "violations:read"
63
+ VIOLATIONS_MANAGE = "violations:manage"
64
+ POLICIES_READ = "policies:read"
65
+ POLICIES_WRITE = "policies:write"
66
+ POLICIES_ROLLBACK = "policies:rollback"
67
+ RULES_READ = "rules:read"
68
+ AUDIT_READ = "audit:read"
69
+ GITHUB_MANAGE = "github:manage"
70
+ MEMBERS_READ = "members:read"
71
+ MEMBERS_MANAGE = "members:manage"
72
+ NOTIFICATIONS_READ = "notifications:read"
73
+ NOTIFICATIONS_MANAGE = "notifications:manage"
74
+ # Phase 8: organization governance.
75
+ ORGANIZATION_READ = "organization:read"
76
+ ORGANIZATION_MANAGE = "organization:manage"
77
+ POLICIES_PUBLISH = "policies:publish"
78
+ POLICIES_APPROVE = "policies:approve"
79
+ POLICIES_EMERGENCY = "policies:emergency"
80
+ RULES_MANAGE = "rules:manage"
81
+ EXCEPTIONS_READ = "exceptions:read"
82
+ EXCEPTIONS_CREATE = "exceptions:create"
83
+ EXCEPTIONS_APPROVE = "exceptions:approve"
84
+ EXCEPTIONS_REVOKE = "exceptions:revoke"
85
+ SECURITY_READ = "security:read"
86
+ SECURITY_MANAGE = "security:manage"
87
+
88
+
89
+ class Role(StrEnum):
90
+ VIEWER = "viewer"
91
+ SECURITY_MANAGER = "security_manager"
92
+ ADMIN = "admin"
93
+ OWNER = "owner"
94
+
95
+ @property
96
+ def rank(self) -> int:
97
+ return list(Role).index(self)
98
+
99
+ @property
100
+ def permissions(self) -> frozenset[Permission]:
101
+ return ROLE_PERMISSIONS[self]
102
+
103
+
104
+ _VIEWER = frozenset(
105
+ {
106
+ Permission.REPOSITORIES_READ,
107
+ Permission.SCANS_READ,
108
+ Permission.VIOLATIONS_READ,
109
+ Permission.POLICIES_READ,
110
+ Permission.RULES_READ,
111
+ Permission.NOTIFICATIONS_READ,
112
+ Permission.ORGANIZATION_READ,
113
+ Permission.EXCEPTIONS_READ,
114
+ Permission.SECURITY_READ,
115
+ }
116
+ )
117
+ _SECURITY_MANAGER = _VIEWER | {
118
+ Permission.VIOLATIONS_MANAGE,
119
+ Permission.SCANS_TRIGGER,
120
+ Permission.AUDIT_READ,
121
+ Permission.EXCEPTIONS_CREATE,
122
+ }
123
+ _ADMIN = _SECURITY_MANAGER | {
124
+ Permission.POLICIES_WRITE,
125
+ Permission.POLICIES_ROLLBACK,
126
+ Permission.POLICIES_PUBLISH,
127
+ Permission.POLICIES_APPROVE,
128
+ Permission.NOTIFICATIONS_MANAGE,
129
+ Permission.REPOSITORIES_MANAGE,
130
+ Permission.GITHUB_MANAGE,
131
+ Permission.MEMBERS_READ,
132
+ Permission.ORGANIZATION_MANAGE,
133
+ Permission.RULES_MANAGE,
134
+ Permission.EXCEPTIONS_APPROVE,
135
+ Permission.EXCEPTIONS_REVOKE,
136
+ Permission.SECURITY_MANAGE,
137
+ }
138
+ _OWNER = _ADMIN | {Permission.MEMBERS_MANAGE, Permission.POLICIES_EMERGENCY}
139
+
140
+ ROLE_PERMISSIONS: Mapping[Role, frozenset[Permission]] = {
141
+ Role.VIEWER: _VIEWER,
142
+ Role.SECURITY_MANAGER: frozenset(_SECURITY_MANAGER),
143
+ Role.ADMIN: frozenset(_ADMIN),
144
+ Role.OWNER: frozenset(_OWNER),
145
+ }
146
+
147
+
148
+ @dataclass(frozen=True, slots=True)
149
+ class AccessScope:
150
+ """What one session may see for one permission.
151
+
152
+ ``installation_ids`` are installations whose account grants the permission
153
+ to the user *and* that GitHub reported as accessible at sign-in.
154
+ ``session_hash`` keys the per-session list of repositories GitHub reported;
155
+ repository-level rows are only visible when listed there.
156
+ """
157
+
158
+ session_hash: str
159
+ installation_ids: tuple[int, ...]
160
+ account_ids: tuple[int, ...]
161
+
162
+ @property
163
+ def empty(self) -> bool:
164
+ return not self.installation_ids and not self.account_ids
165
+
166
+ @property
167
+ def installations_json(self) -> str:
168
+ """The installation IDs as a JSON array, bound to ``json_each(?)`` in queries."""
169
+ return json.dumps(list(self.installation_ids))
170
+
171
+ @property
172
+ def accounts_json(self) -> str:
173
+ return json.dumps(list(self.account_ids))
174
+
175
+
176
+ @dataclass(frozen=True, slots=True)
177
+ class Membership:
178
+ account_id: int
179
+ account_login: str
180
+ account_type: str
181
+ role: Role
182
+ implicit: bool = False # owner of a personal installation
183
+
184
+
185
+ @dataclass(frozen=True, slots=True)
186
+ class Principal:
187
+ """A signed-in user, as established by the session for one request."""
188
+
189
+ user_id: int
190
+ login: str
191
+ session_hash: str
192
+ session_public_id: str
193
+ authenticated_at: datetime
194
+ expires_at: datetime
195
+ memberships: Mapping[int, Membership] = field(default_factory=dict)
196
+ # installation_id -> account_id, for installations GitHub reported at sign-in
197
+ installations: Mapping[int, int] = field(default_factory=dict)
198
+
199
+ def role_in(self, account_id: int) -> Role | None:
200
+ membership = self.memberships.get(account_id)
201
+ return membership.role if membership else None
202
+
203
+ def can(self, permission: Permission, account_id: int) -> bool:
204
+ role = self.role_in(account_id)
205
+ return role is not None and permission in role.permissions
206
+
207
+ def accounts_with(self, permission: Permission) -> tuple[int, ...]:
208
+ return tuple(
209
+ sorted(a for a, m in self.memberships.items() if permission in m.role.permissions)
210
+ )
211
+
212
+ def scope(self, permission: Permission, *, account_id: int | None = None) -> AccessScope:
213
+ accounts = set(self.accounts_with(permission))
214
+ if account_id is not None:
215
+ accounts &= {account_id}
216
+ installations = tuple(
217
+ sorted(i for i, account in self.installations.items() if account in accounts)
218
+ )
219
+ return AccessScope(
220
+ session_hash=self.session_hash,
221
+ installation_ids=installations,
222
+ account_ids=tuple(sorted(accounts)),
223
+ )
224
+
225
+ def permissions_for(self, account_id: int) -> frozenset[Permission]:
226
+ role = self.role_in(account_id)
227
+ return role.permissions if role else frozenset()
228
+
229
+
230
+ def highest_role(roles: Iterable[Role]) -> Role | None:
231
+ return max(roles, key=lambda role: role.rank, default=None)