Skip to content

Commit 77744a9

Browse files
authored
Filter metadata permissions when combined with additional permissions (#56263)
1 parent f694d36 commit 77744a9

File tree

2 files changed

+348
-5
lines changed

2 files changed

+348
-5
lines changed

src/github-apps/scripts/sync.js

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,25 @@ export async function syncGitHubAppsData(openApiSource, sourceSchemas, progAcces
103103

104104
const excludedActors = progActorResources[permissionName]['excluded_actors']
105105

106-
const additionalPermissions =
107-
progAccessData[operation.operationId].permissions.length > 1 ||
108-
progAccessData[operation.operationId].permissions.some(
109-
(permissionSet) => Object.keys(permissionSet).length > 1,
106+
const additionalPermissions = calculateAdditionalPermissions(
107+
progAccessData[operation.operationId].permissions,
108+
)
109+
110+
// Filter out metadata permissions when combined with other permissions
111+
// The metadata permission is automatically granted with any other repository permission,
112+
// so documenting it for operations that require additional permissions is misleading.
113+
// This fixes the issue where mutating operations (PUT, DELETE) incorrectly appeared
114+
// to only need metadata access when they actually require write permissions.
115+
// See: https://github.com/github/docs-engineering/issues/5212
116+
if (
117+
shouldFilterMetadataPermission(
118+
permissionName,
119+
progAccessData[operation.operationId].permissions,
110120
)
121+
) {
122+
continue
123+
}
124+
111125
// github app permissions
112126
if (!isActorExcluded(excludedActors, 'server_to_server', actorTypeMap)) {
113127
const serverToServerPermissions = githubAppsData['server-to-server-permissions']
@@ -332,6 +346,28 @@ function sentenceCase(str) {
332346
return str.charAt(0).toUpperCase() + str.slice(1)
333347
}
334348

349+
/**
350+
* Calculates whether an operation has additional permissions beyond a single permission.
351+
*/
352+
export function calculateAdditionalPermissions(permissionSets) {
353+
return (
354+
permissionSets.length > 1 ||
355+
permissionSets.some((permissionSet) => Object.keys(permissionSet).length > 1)
356+
)
357+
}
358+
359+
/**
360+
* Determines whether a metadata permission should be filtered out when it has additional permissions.
361+
* Prevents misleading documentation where mutating operations appear to only need metadata access.
362+
*/
363+
export function shouldFilterMetadataPermission(permissionName, permissionSets) {
364+
if (permissionName !== 'metadata') {
365+
return false
366+
}
367+
368+
return calculateAdditionalPermissions(permissionSets)
369+
}
370+
335371
export function isActorExcluded(excludedActors, actorType, actorTypeMap = {}) {
336372
if (!excludedActors || !Array.isArray(excludedActors)) {
337373
return false
@@ -358,7 +394,6 @@ export function isActorExcluded(excludedActors, actorType, actorTypeMap = {}) {
358394

359395
return false
360396
}
361-
362397
function addAppData(storage, category, data) {
363398
if (!storage[category]) {
364399
storage[category] = []
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { shouldFilterMetadataPermission, calculateAdditionalPermissions } from '../scripts/sync.js'
3+
4+
describe('metadata permissions filtering', () => {
5+
// Mock data structure representing operations with metadata permissions
6+
const mockOperationsWithMetadata = [
7+
{
8+
operationId: 'repos/enable-automated-security-fixes',
9+
permissionSets: [{ metadata: 'read', administration: 'write' }],
10+
},
11+
{
12+
operationId: 'repos/get-readme',
13+
permissionSets: [{ metadata: 'read' }],
14+
},
15+
{
16+
operationId: 'orgs/update-webhook',
17+
permissionSets: [{ metadata: 'read', organization_administration: 'write' }],
18+
},
19+
{
20+
operationId: 'repos/get-content',
21+
permissionSets: [{ contents: 'read' }],
22+
},
23+
]
24+
25+
// Mock programmatic access data
26+
const mockProgAccessData = {
27+
'repos/enable-automated-security-fixes': {
28+
userToServerRest: true,
29+
serverToServer: true,
30+
permissions: [{ metadata: 'read', administration: 'write' }],
31+
},
32+
'repos/get-readme': {
33+
userToServerRest: true,
34+
serverToServer: true,
35+
permissions: [{ metadata: 'read' }],
36+
},
37+
'orgs/update-webhook': {
38+
userToServerRest: true,
39+
serverToServer: true,
40+
permissions: [{ metadata: 'read', organization_administration: 'write' }],
41+
},
42+
'repos/get-content': {
43+
userToServerRest: true,
44+
serverToServer: true,
45+
permissions: [{ contents: 'read' }],
46+
},
47+
}
48+
49+
// Mock actor resources
50+
const mockProgActorResources = {
51+
metadata: {
52+
title: 'Metadata',
53+
visibility: 'public',
54+
},
55+
administration: {
56+
title: 'Administration',
57+
visibility: 'public',
58+
},
59+
organization_administration: {
60+
title: 'Organization administration',
61+
visibility: 'public',
62+
},
63+
contents: {
64+
title: 'Contents',
65+
visibility: 'public',
66+
},
67+
}
68+
69+
test('calculateAdditionalPermissions works correctly', () => {
70+
// Single permission set with multiple permissions
71+
expect(calculateAdditionalPermissions([{ metadata: 'read', admin: 'write' }])).toBe(true)
72+
73+
// Single permission set with single permission
74+
expect(calculateAdditionalPermissions([{ metadata: 'read' }])).toBe(false)
75+
76+
// Multiple permission sets
77+
expect(calculateAdditionalPermissions([{ metadata: 'read' }, { admin: 'write' }])).toBe(true)
78+
79+
// Empty permission sets
80+
expect(calculateAdditionalPermissions([])).toBe(false)
81+
})
82+
83+
test('identifies metadata with additional permissions correctly', () => {
84+
// Case 1: metadata + administration (should be filtered)
85+
const metadataWithAdmin = [{ metadata: 'read', administration: 'write' }]
86+
expect(shouldFilterMetadataPermission('metadata', metadataWithAdmin)).toBe(true)
87+
88+
// Case 2: metadata only (should NOT be filtered)
89+
const metadataOnly = [{ metadata: 'read' }]
90+
expect(shouldFilterMetadataPermission('metadata', metadataOnly)).toBe(false)
91+
92+
// Case 3: non-metadata permission (should NOT be filtered)
93+
const nonMetadata = [{ contents: 'read' }]
94+
expect(shouldFilterMetadataPermission('contents', nonMetadata)).toBe(false)
95+
})
96+
97+
test('filters metadata operations with additional permissions', () => {
98+
const filteredOperations = []
99+
const metadataPermissions = []
100+
101+
for (const operation of mockOperationsWithMetadata) {
102+
const progData = mockProgAccessData[operation.operationId]
103+
104+
for (const permissionSet of progData.permissions) {
105+
for (const [permissionName] of Object.entries(permissionSet)) {
106+
if (mockProgActorResources[permissionName]?.visibility === 'private') continue
107+
108+
const additionalPermissions = calculateAdditionalPermissions(progData.permissions)
109+
110+
// Apply metadata filtering logic
111+
if (shouldFilterMetadataPermission(permissionName, progData.permissions)) {
112+
// Skip this metadata permission as it has additional permissions
113+
continue
114+
}
115+
116+
if (permissionName === 'metadata') {
117+
metadataPermissions.push({
118+
operationId: operation.operationId,
119+
additionalPermissions,
120+
})
121+
} else {
122+
filteredOperations.push({
123+
operationId: operation.operationId,
124+
permission: permissionName,
125+
additionalPermissions,
126+
})
127+
}
128+
}
129+
}
130+
}
131+
132+
// Should only have metadata permission from the metadata-only operation
133+
expect(metadataPermissions).toHaveLength(1)
134+
expect(metadataPermissions[0].operationId).toBe('repos/get-readme')
135+
expect(metadataPermissions[0].additionalPermissions).toBe(false)
136+
137+
// Should have other permissions from operations with additional permissions
138+
const adminPermission = filteredOperations.find((op) => op.permission === 'administration')
139+
expect(adminPermission).toBeDefined()
140+
expect(adminPermission.operationId).toBe('repos/enable-automated-security-fixes')
141+
expect(adminPermission.additionalPermissions).toBe(true)
142+
143+
const orgAdminPermission = filteredOperations.find(
144+
(op) => op.permission === 'organization_administration',
145+
)
146+
expect(orgAdminPermission).toBeDefined()
147+
expect(orgAdminPermission.operationId).toBe('orgs/update-webhook')
148+
expect(orgAdminPermission.additionalPermissions).toBe(true)
149+
})
150+
151+
test('preserves non-metadata permissions regardless of additional permissions', () => {
152+
const nonMetadataOperations = mockOperationsWithMetadata.filter(
153+
(op) =>
154+
!mockProgAccessData[op.operationId].permissions.some((permSet) => 'metadata' in permSet),
155+
)
156+
157+
expect(nonMetadataOperations).toHaveLength(1)
158+
expect(nonMetadataOperations[0].operationId).toBe('repos/get-content')
159+
160+
// Verify contents permission would be preserved
161+
const contentsPermissionSet = mockProgAccessData['repos/get-content'].permissions[0]
162+
expect('contents' in contentsPermissionSet).toBe(true)
163+
expect('metadata' in contentsPermissionSet).toBe(false)
164+
})
165+
166+
test('handles edge cases in permission sets', () => {
167+
// Empty permission set
168+
expect(shouldFilterMetadataPermission('metadata', [])).toBe(false)
169+
170+
// Permission set with empty object (edge case)
171+
const edgeCase1 = [{ metadata: 'read' }, {}]
172+
expect(shouldFilterMetadataPermission('metadata', edgeCase1)).toBe(true)
173+
174+
// Multiple permission sets with metadata in different sets
175+
const edgeCase2 = [{ metadata: 'read' }, { admin: 'write' }]
176+
expect(shouldFilterMetadataPermission('metadata', edgeCase2)).toBe(true)
177+
})
178+
179+
test('filters metadata permissions that match the GitHub issue examples', () => {
180+
// These are examples from the GitHub issue that should be filtered out
181+
// PUT /orgs/{org}/actions/permissions/repositories/{repository_id}
182+
const putActionsPermissions = [{ metadata: 'read', organization_administration: 'write' }]
183+
184+
// DELETE /orgs/{org}/actions/permissions/repositories/{repository_id}
185+
const deleteActionsPermissions = [{ metadata: 'read', organization_administration: 'write' }]
186+
187+
// These should be filtered out because they have metadata + additional permissions
188+
expect(shouldFilterMetadataPermission('metadata', putActionsPermissions)).toBe(true)
189+
expect(shouldFilterMetadataPermission('metadata', deleteActionsPermissions)).toBe(true)
190+
191+
// But the organization_administration permissions should NOT be filtered
192+
expect(
193+
shouldFilterMetadataPermission('organization_administration', putActionsPermissions),
194+
).toBe(false)
195+
expect(
196+
shouldFilterMetadataPermission('organization_administration', deleteActionsPermissions),
197+
).toBe(false)
198+
})
199+
200+
test('preserves metadata permissions that are standalone', () => {
201+
// Example of a metadata-only permission that should be preserved
202+
const metadataOnlyPermissions = [{ metadata: 'read' }]
203+
204+
// This should NOT be filtered out
205+
expect(shouldFilterMetadataPermission('metadata', metadataOnlyPermissions)).toBe(false)
206+
})
207+
208+
test('handles complex permission structures from real data', () => {
209+
// Multiple permission sets (should filter metadata)
210+
const multiplePermissionSets = [{ metadata: 'read' }, { administration: 'write' }]
211+
expect(shouldFilterMetadataPermission('metadata', multiplePermissionSets)).toBe(true)
212+
213+
// Single permission set with multiple permissions (should filter metadata)
214+
const multiplePermissionsInSet = [
215+
{ metadata: 'read', contents: 'write', pull_requests: 'write' },
216+
]
217+
expect(shouldFilterMetadataPermission('metadata', multiplePermissionsInSet)).toBe(true)
218+
219+
// Multiple permission sets where metadata is not in the first set
220+
const metadataInSecondSet = [{ administration: 'write' }, { metadata: 'read' }]
221+
expect(shouldFilterMetadataPermission('metadata', metadataInSecondSet)).toBe(true)
222+
})
223+
224+
test('validates filtering logic against known problematic endpoints', () => {
225+
// Based on the issue description, these types of operations should have
226+
// their metadata permissions filtered out:
227+
228+
// Runner group operations
229+
const runnerGroupPermissions = [{ metadata: 'read', organization_administration: 'write' }]
230+
231+
// Organization secrets operations
232+
const orgSecretsPermissions = [{ metadata: 'read', organization_secrets: 'write' }]
233+
234+
// Repository operations with admin permissions
235+
const repoAdminPermissions = [{ metadata: 'read', administration: 'write' }]
236+
237+
// All of these should filter out metadata
238+
expect(shouldFilterMetadataPermission('metadata', runnerGroupPermissions)).toBe(true)
239+
expect(shouldFilterMetadataPermission('metadata', orgSecretsPermissions)).toBe(true)
240+
expect(shouldFilterMetadataPermission('metadata', repoAdminPermissions)).toBe(true)
241+
242+
// But should preserve the actual required permissions
243+
expect(
244+
shouldFilterMetadataPermission('organization_administration', runnerGroupPermissions),
245+
).toBe(false)
246+
expect(shouldFilterMetadataPermission('organization_secrets', orgSecretsPermissions)).toBe(
247+
false,
248+
)
249+
expect(shouldFilterMetadataPermission('administration', repoAdminPermissions)).toBe(false)
250+
})
251+
252+
test('verifies consistency with additional-permissions flag calculation', () => {
253+
const testCases = [
254+
// Single permission, single set - no additional permissions
255+
{ permissionSets: [{ metadata: 'read' }], expected: false },
256+
257+
// Multiple permissions, single set - has additional permissions
258+
{ permissionSets: [{ metadata: 'read', admin: 'write' }], expected: true },
259+
260+
// Single permission, multiple sets - has additional permissions
261+
{ permissionSets: [{ metadata: 'read' }, { admin: 'write' }], expected: true },
262+
263+
// Multiple permissions, multiple sets - has additional permissions
264+
{
265+
permissionSets: [{ metadata: 'read', contents: 'read' }, { admin: 'write' }],
266+
expected: true,
267+
},
268+
]
269+
270+
for (const testCase of testCases) {
271+
const additionalPermissions = calculateAdditionalPermissions(testCase.permissionSets)
272+
const shouldFilter = shouldFilterMetadataPermission('metadata', testCase.permissionSets)
273+
274+
// The filtering logic should match the additional permissions calculation
275+
expect(shouldFilter).toBe(additionalPermissions)
276+
expect(additionalPermissions).toBe(testCase.expected)
277+
}
278+
})
279+
280+
test('validates filtering logic matches expected behavior from issue', () => {
281+
// Based on the GitHub issue, these operations should be filtered out from metadata:
282+
// - PUT /orgs/{org}/actions/permissions/repositories/{repository_id}
283+
// - DELETE /orgs/{org}/actions/permissions/repositories/{repository_id}
284+
// Because they have metadata + organization_administration permissions
285+
286+
const mockMutatingOperation = {
287+
operationId: 'actions/set-selected-repositories-enabled-github-actions-organization',
288+
permissionSets: [{ metadata: 'read', organization_administration: 'write' }],
289+
}
290+
291+
const progData = {
292+
userToServerRest: true,
293+
serverToServer: true,
294+
permissions: [{ metadata: 'read', organization_administration: 'write' }],
295+
}
296+
297+
// This should be filtered out from metadata permissions
298+
expect(shouldFilterMetadataPermission('metadata', progData.permissions)).toBe(true)
299+
300+
// But organization_administration permission should still be included
301+
expect(
302+
shouldFilterMetadataPermission('organization_administration', progData.permissions),
303+
).toBe(false)
304+
305+
// Verify additional permissions flag is set correctly
306+
expect(calculateAdditionalPermissions(progData.permissions)).toBe(true)
307+
})
308+
})

0 commit comments

Comments
 (0)