Skip to content

Commit 224de61

Browse files
hosomCopilot
andcommitted
Enforce individual-only repository grants
Remove all repository team associations and undeclared direct grants, including outside collaborators. Preserve team hierarchy and membership, order user upserts before cleanup, and verify snapshots before and after application. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a8107bce-2500-45f8-b046-3f50092d9fbd
1 parent d12e39a commit 224de61

5 files changed

Lines changed: 265 additions & 39 deletions

File tree

‎README.md‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ Entitlements configs can contain metadata which the plugin will use to make furt
8888
8989
### GitHub repositories
9090
91-
The `github_repository` backend manages **direct, user-only repository grants for active, non-owner organization members**. It does not create or delete repositories. Load `entitlements/backend/github_repository` in your plugin loader and add this entry under `groups`:
91+
The `github_repository` backend enforces **individual-only repository grants**. Role files define the desired direct grants; undeclared direct user grants (including outside collaborators and explicit owner grants) and **all repository team grants** are removed when `remove` is enabled. New grants are restricted to active, non-owner organization members. It does not create or delete repositories. Load `entitlements/backend/github_repository` in your plugin loader and add this entry under `groups`:
9292
9393
```yaml
9494
github.com/github/repositories:
@@ -129,7 +129,7 @@ username = bob; expiration = 2027-01-01
129129
group = engineering/platform
130130
```
131131
132-
Group references, filters, and expiration are evaluated by the normal Entitlements rules engine. People must resolve through the configured people data source, with their `uid` equal to their GitHub login. As with other backends, the underlying username rule omits people absent from that data source; validate such references in your configuration CI. `ignore_not_found` applies to evaluated people missing active GitHub organization membership, not to missing repositories or API failures.
132+
Group references, filters, and expiration are evaluated by the normal Entitlements rules engine. Group references expand to individual users, never GitHub team grants. People must resolve through the configured people data source, with their `uid` equal to their GitHub login. As with other backends, the underlying username rule omits people absent from that data source; validate such references in your configuration CI. `ignore_not_found` applies to evaluated people missing active GitHub organization membership, not to missing repositories or API failures.
133133
134134
YAML and Ruby role files are also supported when enabled in `allowed_types`, e.g. `[txt, yaml, rb]`. If omitted, all three formats are allowed. Ruby files use the standard Entitlements Ruby rule-class convention (repository directory and role filename determine the class); enable them only for trusted configuration authors. `allowed_methods` constrains declarative rules, not arbitrary Ruby code.
135135
@@ -143,46 +143,52 @@ YAML and Ruby role files are also supported when enabled in `allowed_types`, e.g
143143
144144
Custom roles are not supported. Unsupported direct roles or incomplete API responses abort reconciliation rather than falling back to effective permissions. A user cannot occur in multiple roles, even with different capitalization. Comparisons are case-insensitive; difference logs preserve login capitalization and show old and new roles. Duplicate role files, unsupported extensions, symlinks, nested directories, and unexpected files (including README and hidden files) are rejected. Keep documentation outside the managed root.
145145
146-
**A missing role file means no desired direct members for that role.** An empty repository directory therefore requests removal of all managed direct grants if `remove` is enabled. To keep an explicitly empty role file, use the standard `metadata_no_conditions_ok = true` text directive. **Deleting the entire repository directory opts that repository out without cleanup**; existing access is untouched. The configured root must still exist. Git does not track empty directories, so keep an explicit empty role file when intending to remove every managed grant.
146+
**A missing role file means no desired direct members for that role.** An empty repository directory therefore requests removal of all managed direct user and team grants if `remove` is enabled. To keep an explicitly empty role file, use the standard `metadata_no_conditions_ok = true` text directive. **Deleting the entire repository directory opts that repository out without cleanup**; existing access is untouched. The configured root must still exist. Git does not track empty directories, so keep an explicit empty role file when intending to remove every managed grant.
147147
148148
#### Ownership boundary and feature flags
149149
150-
Only a `Repository` permission source is used to determine a current direct role, even when a team or organization gives the person a higher effective permission. Team, organization, enterprise-team, and owner grants are not managed. Outside collaborators and organization owners are excluded entirely; desired owners and non-members fail validation by default. With `ignore_not_found: true`, they are skipped with a warning. This backend does not invite people into the organization or manage pending organization invitations.
150+
Only a `Repository` permission source determines a current direct user role, even when a team or organization gives the person higher effective access. Direct grants are read regardless of organization membership. Desired owners and non-members still fail validation by default; with `ignore_not_found: true`, they are ignored with a warning. This backend does not invite people into the organization or manage pending organization invitations.
151+
152+
Every managed repository also opts into removal of all organization-team repository associations, including empty teams. There is no team manifest or team allowlist. Team membership, hierarchy, and access to other repositories remain unchanged. Parent associations are removed before child associations; the backend re-reads the team list before each removal because inherited child access may disappear with its parent. Diffs report one removal per observed team, not a collaborator deletion for each team member. Exact team roles are unnecessary because no team grant is desired.
153+
154+
Organization base permissions, organization-owner privileges, and public/internal repository visibility are outside repository-grant management and remain unchanged. The diff explicitly notes this boundary: removing a grant does not necessarily remove all of a person's effective access. Public repositories remain publicly readable. Encountering an `EnterpriseTeam` permission source aborts reconciliation because this backend cannot remove that association; it does not silently claim convergence.
151155
152156
`ignore` is an array of logins removed from both sides of the diff, case-insensitively. Ignored users' grants are never mutated. Ignoring a user does not bypass schema/response validation when reading the repository. No fallback to effective permissions is performed.
153157
154-
`features` defaults to `[add, update, remove]`. `add` permits new direct grants, `update` permits role changes, and `remove` permits deleting direct grants absent from desired state. Disabled operations are suppressed in both actions and displayed state. `features: []` performs reads and validation but produces no changes. To inspect the full proposed diff without applying it, use Entitlements' no-op mode with all features enabled.
158+
`features` defaults to `[add, update, remove]`. `add` permits new direct grants, `update` permits role changes, and `remove` permits deleting undeclared direct users and all repository team associations. Disabled operations are suppressed in both actions and displayed state. If removals are disabled while teams remain, the backend warns that individual-only grants are not enforced. `features: []` performs reads and validation but produces no changes. Feature restrictions and ignored users are policy exceptions; use all features and an empty ignore list for authoritative enforcement. To inspect the full proposed diff without applying it, use Entitlements' no-op mode with all features enabled.
155159
156-
Role changes issue one `PUT`, never a remove followed by an add. All upserts for a repository precede removals. Removing a direct grant does not remove inherited access; it also has GitHub's documented side effects on forks and other resources. Review the [collaborator API documentation](https://docs.github.com/en/rest/collaborators/collaborators) before enabling removal. GitHub may reject a direct role below organization base permissions.
160+
Role changes issue one `PUT`, never a remove followed by an add. All desired user upserts for a repository precede user and team removals. Removing grants has GitHub's documented side effects on forks and other resources. Review the [collaborator API documentation](https://docs.github.com/en/rest/collaborators/collaborators) before enabling removal. GitHub may reject a direct role below organization base permissions.
157161
158162
#### GitHub App permissions and validation gate
159163
160164
Install the App on every managed repository with:
161165
162166
| Scope | Permission | Use |
163167
|-------|------------|-----|
164-
| Repository | **Administration: write** | Add, change, and remove collaborator grants |
168+
| Repository | **Administration: write** | Add, change, and remove collaborator grants; remove team repository associations |
165169
| Repository | **Metadata: read** (automatically granted) | Repository visibility |
166-
| Organization | **Members: read** | Active organization members and owners |
170+
| Organization | **Members: read** | Active organization members, owners, and repository teams |
167171
168172
These REST requirements are listed in [GitHub's App permission reference](https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps). Repository reads require access to `collaborators(affiliation: DIRECT)`, `permissionSources`, and each direct source's `roleName`; see the [GraphQL schema](https://docs.github.com/en/graphql/reference/repos). **Live installation-token access to these fields has not been validated by the unit suite.** Before deploying, verify it using the actual App installation and GitHub/GHES version. If those fields are unavailable, do not enable mutations or substitute effective REST permissions.
169173
170174
A live GitHub.com probe with a classic OAuth token confirmed that `permissionSources` and `roleName` require the `admin:org` scope; `repo` plus `read:org` was rejected. This is a classic-token scope requirement, not evidence that an App installation token has access. Validate the App separately rather than broadening an operator's token automatically.
171175
172176
The backend deliberately does not request the unused effective `permission` field. A live CI calculation with an `admin:org`-only token showed that field requires an additional `public_repo` scope. Exact direct roles come from `permissionSources.roleName`, so requesting effective permissions would add an unnecessary credential requirement.
173177
174-
In a designated disposable repository, give an active organization member a direct role and a different, higher inherited role. Verify the direct source reports the lower role, then add/change/remove the direct grant using the installation token. Expect `204` for organization-member adds, updates, and removals. `201` indicates an invitation rather than active access; the backend warns and does not cache it as a completed grant. Confirm inherited and outside access remain unchanged and a subsequent run has no diff. Do not run this check against production accounts or repositories.
178+
In a designated disposable repository, give an active organization member a direct role and a different, higher team role. Include an undeclared outside collaborator and parent/child teams, including an empty team. Verify reads using the actual installation token, then reconcile individual-only access. Expect `204` for user upserts/removals and team association removals. `201` indicates an invitation rather than active access; the backend warns and post-apply verification must not mistake it for convergence. Confirm team memberships and other repositories remain unchanged, undeclared user/team grants disappear, and a subsequent run has no diff. Do not run this check against production accounts or repositories.
175179
176180
#### API usage, failure behavior, and rollout
177181
178-
Repository reads use one GraphQL request per page of up to 100 direct collaborators, at least one per managed repository. Pagination follows `hasNextPage` and rejects missing/repeated cursors. Snapshots are cached in memory for the service's lifetime and invalidated after successful or partial applies. There is no persistent repository cache or `entitlements-caches` integration.
182+
Repository reads use one GraphQL request per page of up to 100 direct collaborators, plus paginated REST repository-team reads (100 per page). GraphQL pagination follows `hasNextPage` and rejects missing/repeated cursors; team reads use Octokit's automatic Link pagination. Snapshots include team identities and hierarchy independently of user membership. Snapshots are cached in memory for the service's lifetime and invalidated after successful or partial applies. There is no persistent repository cache or `entitlements-caches` integration.
183+
184+
Before application, a fresh snapshot must match the calculated existing state (excluding ignored users), otherwise the backend aborts and requires recalculation. After application, another fresh snapshot must match the feature-controlled target state. Residual grants or partial failures are explicit errors, never successful-looking convergence. These checks detect drift but are not an atomic transaction with GitHub; concurrent administrators can still change access during a run.
179185
180186
Organization membership uses the shared per-run cache (paginated REST reads for `admin` and `member`, 100 users per page); predictive membership is refreshed before authorizing repository grants. Each added or changed grant requires one REST `PUT`, and each removed grant one `DELETE`, excluding retries. Octokit's existing middleware retries server errors on idempotent mutations; authorization, validation, and abuse/rate-limit responses abort without application-level retries. GraphQL uses the shared bounded retry transport. A partial failure stops application, leaves already-applied grants in place, and invalidates the snapshot. Re-run after resolving the failure; changes are not rolled back automatically.
181187
182188
1. Complete the disposable-repository App validation above.
183189
2. Start with a small set of repository directories and no-op mode; compare direct roles with repository settings. `features: []` is also safe for read/validation checks but suppresses the diff.
184190
3. Enable only `add` and `update`, then confirm successive runs converge.
185-
4. Review ignored accounts, inherited access, and outside collaborators before enabling `remove`.
191+
4. Review ignored accounts, organization-level access, outside collaborators, and all repository team associations before enabling `remove`. Existing team-based write/admin access will be removed even when team members are declared individually.
186192
5. Expand gradually while measuring GraphQL cost, REST rate usage, runtime, and failure rates. Consider persistent caching only if measurements justify it.
187193
188194
Production convergence and API budgets require this live rollout; mocked tests do not establish them.

‎lib/entitlements/backend/github_repository/models/repository_access.rb‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ class Backend
55
class GitHubRepository
66
module Models
77
class RepositoryAccess < Entitlements::Models::Group
8-
attr_reader :repository, :roles
8+
attr_reader :repository, :roles, :teams
99

10-
def initialize(repository:, roles:, ou:)
10+
def initialize(repository:, roles:, ou:, teams: [])
1111
Configuration.validate_repository!(repository)
1212
@repository = repository
1313
@roles = {}
@@ -22,9 +22,35 @@ def initialize(repository:, roles:, ou:)
2222
end
2323
@roles.freeze
2424
@logins.freeze
25+
@teams = {}
26+
slugs = Set.new
27+
teams.each do |team|
28+
unless team.is_a?(Hash) && team[:id].is_a?(Integer) && team[:id].positive? &&
29+
team[:slug].is_a?(String) && /\A[a-zA-Z0-9_-]+\z/.match?(team[:slug]) &&
30+
(team[:parent_id].nil? || (team[:parent_id].is_a?(Integer) && team[:parent_id].positive?))
31+
GitHubRepository.fail!("Malformed repository team: #{team.inspect}")
32+
end
33+
if @teams.key?(team[:id]) || !slugs.add?(team[:slug].downcase)
34+
GitHubRepository.fail!("Duplicate repository team: #{team[:slug]}")
35+
end
36+
@teams[team[:id]] = team.dup.freeze
37+
end
38+
@teams.freeze
39+
ordered_teams
2540
super(dn: "cn=#{repository},#{ou}", members: Set.new(@logins.values))
2641
end
2742

43+
def ordered_teams
44+
remaining = teams.dup
45+
ordered = []
46+
until remaining.empty?
47+
roots = remaining.values.reject { |team| remaining.key?(team[:parent_id]) }.sort_by { |team| team[:slug].downcase }
48+
GitHubRepository.fail!("Cyclic repository team hierarchy") if roots.empty?
49+
roots.each { |team| ordered << remaining.delete(team[:id]) }
50+
end
51+
ordered
52+
end
53+
2854
def role_for(login)
2955
roles[login.downcase]
3056
end
@@ -34,7 +60,7 @@ def login_for(login)
3460
end
3561

3662
def equals?(other)
37-
other.is_a?(self.class) && dn.casecmp?(other.dn) && roles == other.roles
63+
other.is_a?(self.class) && dn.casecmp?(other.dn) && roles == other.roles && teams == other.teams
3864
end
3965

4066
alias_method :==, :equals?

0 commit comments

Comments
 (0)