Skip to content

feat(server): add support for minting enterprise-scoped installation access tokens - #254

Draft
pdewilde wants to merge 5 commits into
mainfrom
pdewilde/add-enterprise-scoped-token
Draft

feat(server): add support for minting enterprise-scoped installation access tokens#254
pdewilde wants to merge 5 commits into
mainfrom
pdewilde/add-enterprise-scoped-token

Conversation

@pdewilde

@pdewilde pdewilde commented Jul 29, 2026

Copy link
Copy Markdown

Summary

This pull request adds support for minting Enterprise-scoped Installation Access Tokens in GitHub Token Minter (github-token-minter).

Motivation

In GitHub Enterprise Cloud, programmatic app installation across organizations without manual OAuth UI interaction is only possible via GitHub's Enterprise App Installation API:
POST /enterprises/{enterprise}/apps/organizations/{org}/installations

GitHub enforces a strict security boundary for administrative endpoints: Enterprise Admin endpoints (/enterprises/{enterprise}/...) can only be accessed using an Enterprise-scoped Installation Access Token (/app/installations/<enterprise-installation-id>/access_tokens). Prior to this update, GHTM exclusively supported Organization-scoped installations (InstallationForOrg), whose tokens are categorically rejected by Enterprise endpoints with HTTP 403 Resource not accessible by integration.


Architectural & Security Highlights

1. Allowlist Scope Governance (--enterprise-config-orgs)

To prevent unauthorized child organizations from defining scope rules that elevate privileges to Enterprise permissions, this update introduces the --enterprise-config-orgs flag (ENTERPRISE_CONFIG_ORGS environment variable):

  • Default Deny: If --enterprise-config-orgs is unconfigured or empty, Enterprise token minting is disabled (HTTP 403 Forbidden).
  • Scoped Evaluation: When configured, GHTM evaluates requested enterprise scopes exclusively against the allowlisted organization(s). Any .minty config in non-allowlisted organizations cannot grant Enterprise tokens.

2. Strict Request Validation

  • Added an optional enterprise field (json:"enterprise,omitempty") to TokenRequest.
  • When enterprise is requested, GHTM verifies that org_name and repositories are empty (HTTP 400 Bad Request if set), ensuring clear separation between Organization/Repository tokens and Enterprise tokens.

3. Enterprise Installation Resolution

  • Implemented installationForEnterprise and MintEnterpriseAccessToken in gitHubSourceSystem (pkg/server/source/github.go).
  • Queries GET /enterprises/{enterprise}/installation to resolve the App's Enterprise Installation ID and issues a token scoped to the Enterprise account.

Testing

  • Added unit tests covering:
    • happy_path_enterprise_token: Validates successful token issuance when enterprise-config-orgs is allowlisted.
    • unhappy_path_enterprise_token_not_enabled: Validates HTTP 403 rejection when enterprise-config-orgs is unconfigured.
    • unhappy_path_enterprise_token_with_org_or_repos: Validates HTTP 400 rejection when mixing enterprise with org_name or repositories.
  • Verified all unit and integration tests pass across pkg/server, pkg/config, and pkg/server/source.

Comment thread pkg/server/token_minter.go Outdated
if err = validatePermissions(scope.Permissions, request.Permissions); err != nil {
return &apiResponse{http.StatusForbidden, "requested permissions are not authorized for this scope", err}
}
if s.enforceReadOnly {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is relevant for enterprise scoped tokens

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Antigravity: This logic handles the global --enforce-read-only flag to ensure any minted token (including enterprise tokens) respects read-only mode if enabled globally.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My point is there are a lot of claim scopes besides those two which can mutate. This is also relevant outside of enterprise scoped tokens as well.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Antigravity: Done. Replaced hardcoded contents/metadata map with enforceReadOnlyPermissions, which dynamically downgrades any requested/scope permission to "read" rather than stripping other permission keys.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets undo that for now. I misunderstood the existing state and thought it was only downgrading those two, but it instead just replaces the full permisison list with those two. I'm still not sure it makes sense for enterprise scope, maybe we should just throw an error if that read only permissions flag is set, I don't want to possibly escalate the permisssion we allow right now compared to existing state, as there may be a good reason we do it the way we do.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Antigravity: Done. Reverted standard org read-only behavior back to its original contents/metadata map, and updated enterprise token handling to explicitly return HTTP 403 Forbidden ("read-only mode is not supported for enterprise scoped tokens") when --enforce-read-only is active.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Antigravity: Done. Updated the error response for enterprise token requests in read-only mode to return HTTP 400 Bad Request.

Comment thread pkg/server/token_minter.go
Comment thread pkg/server/token_minter_test.go
Comment thread pkg/server/source/github.go Outdated
@pdewilde
pdewilde force-pushed the pdewilde/add-enterprise-scoped-token branch from b3e8757 to 4e9b3b5 Compare July 29, 2026 23:20
Comment thread pkg/server/token_minter.go Outdated
Comment thread pkg/server/token_minter.go Outdated
Comment thread pkg/server/token_minter_test.go
Comment thread pkg/server/source/github.go
return "", fmt.Errorf("errors retrieving GitHub enterprise installation: %w", errors.Join(errs...))
}

allRepoRequest := &githubauth.TokenRequestAllRepos{Permissions: map[string]string{}}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the right type of token?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Antigravity: Yes, AccessTokenAllRepos sends POST /app/installations/{installation_id}/access_tokens with {"permissions": ...} without specifying a repository array, which is the required GitHub payload for an Enterprise App installation access token.

Comment thread pkg/server/token_minter.go Outdated

// apiResponse is a structure that contains a http status code,
// a string response message and any error that might have occurred
// apiResponse represents a HTTP status code and message to be returned

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you modify this comment?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Antigravity: Restored the original comment text on apiResponse.

return nil, fmt.Sprintf("%s/%s", org, repo), fmt.Errorf("error reading configuration, exhausted all possible source locations, failed to locate scope [%s] for repository [%s/%s].\nEvaluation results:\n%s", scope, org, repo, strings.Join(failureReasons, "\n"))
}

func (l *configEvaluator) EvalEnterprise(ctx context.Context, scope string, token interface{}) (*Scope, string, error) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic should be unit tested

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Antigravity: Done. Added dedicated unit tests TestConfigEvaluator_EvalEnterprise in pkg/config/config_evaluator_test.go covering disabled state, multi-org fallback, and all-org failure scenarios.

expResp: "",
},
{
name: "happy_path_enterprise_token",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if we have the same scope name in multiple allow listed orgs? If it finds one which fails, will it try rest of orgs or say there is an error? We should have a test for this behavior.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Antigravity: Done. EvalEnterprise iterates through allowlisted orgs in order: if a scope in an earlier org fails evaluation (e.g. claim condition mismatch), it continues checking subsequent allowlisted orgs until a match succeeds. Added unit test fallback_to_second_allowlisted_org verifying this exact behavior.

Comment thread pkg/server/config.go
Comment thread pkg/config/config_evaluator_test.go Outdated
}
})

t.Run("fallback_to_second_allowlisted_org", func(t *testing.T) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we pelase switch to using the table driven tests seen elsewhere in this project.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Antigravity: Done. Refactored TestConfigEvaluator_EvalEnterprise in pkg/config/config_evaluator_test.go to use a clean table-driven test pattern.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant