Detect stale GitHub App permissions during admin install - #320
Conversation
Site previewPreview: https://b47fc802-site.fullsend-ai.workers.dev Commit: |
Parse permissions from the ListOrgInstallations API response and compare against expected permissions from AgentAppConfig. All roles are checked before exiting so the user sees every mismatch at once. Fixes #319 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
72a6e5b to
64e96f3
Compare
ralphbean
left a comment
There was a problem hiding this comment.
Three items to address — overall the approach is solid.
Move checkPermissions inside reuse path (only when secret exists), add warning for nil permissions, and use json round-trip instead of manual map for expected permissions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ralphbean
left a comment
There was a problem hiding this comment.
Review: #320
Head SHA: d033833
Timestamp: 2026-04-22T00:00:00Z
Outcome: request-changes
Summary
This PR adds detection of stale GitHub App permissions during admin install by comparing installed permissions against manifest expectations. The implementation has a critical flaw in the checkPermissions method that uses json.Marshal/json.Unmarshal to convert a struct to map[string]string, which fails to account for struct field naming vs. JSON tag naming. The implementation also silently swallows marshaling errors, which could mask bugs. Additionally, there is a potential issue with state accumulation across multiple Run() calls on the same Setup instance.
Findings
Critical
-
[naming-mismatch]
internal/appsetup/appsetup.go:280-282— ThecheckPermissionsmethod usesjson.Marshalthenjson.Unmarshalto convertAppPermissionstomap[string]string. This breaks because the JSON tags use snake_case (e.g.,pull_requests) while the API response uses the same snake_case, BUT the struct field names are PascalCase. When the code doesfor perm, level := range want, it's iterating over keys like"pull_requests", but then checksinst.Permissions[perm]which also has"pull_requests". This actually works correctly because BOTH sides use the JSON tag names. However, the approach is fragile and non-obvious.The real problem is that this relies on JSON serialization round-tripping, which is indirect and error-prone. If someone changes the struct tags or adds a field without the correct tag, the check will silently break.
Remediation: Replace the marshal/unmarshal dance with direct field access. Example:
expected := ghTypes.AgentAppConfig(org, role).Permissions checks := []struct{ field, level string }{ {"issues", expected.Issues}, {"pull_requests", expected.PullRequests}, {"checks", expected.Checks}, {"contents", expected.Contents}, {"workflows", expected.Workflows}, {"administration", expected.Administration}, {"members", expected.Members}, } var missing []string for _, c := range checks { if c.level == "" { continue } if inst.Permissions[c.field] != c.level { missing = append(missing, fmt.Sprintf("%s:%s", c.field, c.level)) } }
-
[error-suppression]
internal/appsetup/appsetup.go:280-282— Bothjson.Marshalandjson.Unmarshalerrors are silently ignored with_ =. If marshaling fails,wantwill be empty and no permissions will be checked. If unmarshaling fails,wantwill also be empty. This makes the check silently ineffective if the struct cannot be serialized for any reason.Remediation: Check both errors and log or fail explicitly if they occur. Since this is unexpected (the struct should always be marshalable), it's reasonable to either panic or log an error and skip the check with a clear warning.
High
-
[state-accumulation]
internal/appsetup/appsetup.go:106— ThepermErrorsfield onSetupaccumulates state across multipleRun()calls. If the sameSetupinstance is reused (as it is inadmin.golines 378-383), subsequentRun()calls will append to the samepermErrorsslice. This is by design per the testTestSetup_StalePermissions_AllRolesCheckedwhich relies on this behavior. However, the field is not documented, and there's noReset()method. If a caller creates aSetupinstance and callsRun()multiple times in different contexts (not just for multiple roles in one install), the errors will accumulate incorrectly.Remediation: Document the stateful behavior clearly in the
Setupstruct comment. Consider adding aReset()method or makingPermissionErrors()clear the errors after returning them to prevent accidental reuse. Alternatively, makePermissionErrors()consume the errors (move them out of the struct). -
[flow-ordering]
internal/cli/admin.go:395-397— The call tosetup.PermissionErrors()happens AFTER all roles have been processed (lines 378-393). This is correct per the design (accumulate all mismatches, then exit once). However, there is a subtle issue: ifsetup.Run()returns an error for any role (e.g., network failure, client ID lookup failure), the loop short-circuits andPermissionErrors()is never called. This means that if role 1 has stale permissions but role 2 fails to set up due to an unrelated error, the user will only see the role 2 error, not the stale permissions warning for role 1.Remediation: Consider checking permissions before other error-prone operations in
handleExistingApp, or accumulate permission errors even if later roles fail. One option is to continue the loop even on error (collect all role setup errors), then report both setup failures and permission mismatches at the end.
Medium
-
[test-coverage]
internal/appsetup/appsetup_test.go:308-352— The tests do not cover the case whereinst.Permissionsisnil. The code handles this (line 275-277), but there's no test verifying that the warning is printed and the check is skipped. Add a test case for this scenario. -
[test-coverage]
internal/appsetup/appsetup_test.go:354-383— The tests do not cover the case where marshaling or unmarshaling fails (since this is currently silently ignored). Once error handling is added per the critical finding, add a test that verifies the error is logged/handled correctly.
Low
None.
Info
- [convention]
internal/appsetup/appsetup.go:274— The methodcheckPermissionsis unexported butPermissionErrors()is exported. This is intentional (the check happens internally, the caller only consumes the result), but the naming asymmetry (checkPermissionsvsPermissionErrors) is slightly awkward. Consider renamingPermissionErrors()toPermissionCheckErrors()orGetPermissionErrors()for clarity.
Footer
Outcome: request-changes
This review applies to SHA d033833e31d067cdd048b029c19801af3f68f5a5. Any push to the PR head clears this review and requires a new evaluation.
ralphbean
left a comment
There was a problem hiding this comment.
LGTM. Clean implementation — parses permissions from the installations API, compares against expected manifest values, and accumulates mismatches across all roles before exiting. Tests cover both stale and correct permission scenarios.
Summary
permissionsfrom the/orgs/{org}/installationsAPI response (already returned, was being ignored)AgentAppConfig()expectations duringadmin installFixes #319
Test plan
make go-test— new and existing tests passadmin installon an org with stale app permissions — exits with error listing all apps and missing permissionsadmin installon an org with correct permissions — proceeds normally🤖 Generated with Claude Code