Visitar URL original
A GitHub App bypass actor on a ruleset cannot be applied when otterdog authenticates with an installation access token · Issue #772 · eclipse-csi/otterdog · GitHub
Skip to content

A GitHub App bypass actor on a ruleset cannot be applied when otterdog authenticates with an installation access token #772

Open

Description

@c-vigo

Environment

otterdog 1.5.0, CLI (otterdog apply --local --no-web-ui --force), authenticating
with an installation access token minted for a GitHub App
(OTTERDOG_TOKEN). The organization is on GitHub Team; the repository is private.

What happens

A repository ruleset declaring a GitHub App as a bypass actor:

orgs.newRepoRuleset('<name>') {
  allows_creations: false,
  bypass_actors+: ['<app-slug>'],
  include_refs+: ['refs/tags/*/v*'],
  target: 'tag',
}

validate and plan both pass and render the change as expected. apply then
fails, atomically — nothing is written:

Error:   failed to apply patch: CHANGE - repo_ruleset[name="<name>", repository=<repo>]
         failed retrieving app node id:
         Exception while accessing 'https://api.github.com/apps/<app-slug>':
         (status=403, body={"message":"Resource not accessible by integration", ...})

Cause

On the write path, models/ruleset.py treats any bypass actor that is
neither #role nor @team as an App slug and resolves it through
rest_api.app.get_app_ids(slug) → GET /apps/{app_slug}. That endpoint is not
available to installation access tokens; it needs a JWT or a user token.

The read path in the same file takes app_slug straight from the ruleset
API response and makes no second call, so importing, plan and drift detection
all work. Only the first write of an App bypass actor fails — which is why an
App bypass actor that was already live when it was declared never trips this,
and a removal also works, since only the actors being written are resolved.

providers/github/__init__.py constructs the provider with
token_auth(self._credentials.github_token); the app_auth JWT strategy is not
used on this path and credential providers only yield a token, so a caller
cannot work around this by supplying App credentials instead.

models/branch_protection_rule.py resolves app node ids the same way
(get_app_node_ids), so branch protection rules with App bypass actors look
like the same class of problem.

Impact

Any App bypass actor is unwritable under installation-token auth, and because
the failure is at write time, plan offers no warning: a change reviewed green
fails mid-apply against the live organization. Once such a ruleset exists (it
can be created out of band through the REST API), every subsequent change to it
hits the same failure, because a ruleset patch rewrites its whole bypass list —
including otterdog's own reconciliation of a manual edit to it.

Precedent

This is the same class of defect as #695, on the other half of the model. There,
a required status check reported only by integration_id was resolved as an App
slug and GET /apps/<id> 404'd; the fix was to accept a digits-only token and
map it straight to integration_id, which is why
required_status_checks now handles numeric ids in models/ruleset.py.
Bypass actors never got the equivalent, so they still take the slug path and
still need a credential class the CLI cannot obtain. (#732, still open, is a
third variant of the same resolution problem on the read side.)

Possible fixes

  1. Accept a numeric app id for bypass actors, exactly as apply cannot patch a ruleset whose required status check is reported only by integration_id — import emits a numeric <id>:<context> token that apply resolves as an app *slug* → GET /apps/<id> 404s #695 did for status
    checks. The REST API takes actor_id directly, so no lookup is needed when
    the id is given, and no credential question arises.
  2. Resolve the slug through an endpoint an installation token can read — the
    organization's installations listing would do it where the credential
    carries the necessary organization permission — falling back to
    GET /apps/{app_slug} otherwise.
  3. Failing either, detect the unresolvable actor during plan, so the failure
    surfaces at review time rather than part-way through a live apply.

Activity

  1. added
    ai-triagedConfidently classified by the automated triage agent; remove this label to force re-triage
    priority:p1Next up, high priority
    severity:highMajor functionality broken, no reasonable workaround
    type:bugSomething isn't working
    on Sep 24, 2026
  2. github-actions commented on Sep 24, 2026

    @github-actions
    Contributor

    Summary: GitHub App bypass actors on rulesets (and branch protection rules) fail to apply when otterdog authenticates with an installation access token, because the write path resolves app slugs via GET /apps/{slug}, an endpoint installation tokens cannot access.
    Label changes: added type:bug (confirmed defect), component:github-api (REST API app resolution), component:configuration (ruleset/branch-protection model), severity:high (write fails with no workaround, mid-apply), priority:p1 (affects any org using App bypass actors with installation-token auth), effort:small (mirrors existing digits-only fix from #695), ai-triaged.
    Feasibility & impact:

    Generated by Issue Triage for #772 · copilot · auto · 30.2 AIC · ⌖ 6.03 AIC · ⊞ 7.6K · ◷

  3. added a commit that references this issue on Sep 29, 2026
  4. c-vigo commented on Oct 4, 2026

    @c-vigo
    ContributorAuthor

    One correction to the Cause section, and an argument for fix 2 over fix 1.

    GET /apps/{app_slug} is readable by an installation token — for a public App

    The issue says the endpoint "is not available to installation access tokens; it
    needs a JWT or a user token". That is too strong, and the difference changes the
    scope of the bug. The determinant is the App's visibility, not the credential
    class:

    App unauthenticated installation access token
    public 200 200
    private 404 403

    Measured on two App bypass actors declared in the same organization, resolved
    with the same installation token on the same run: the public one resolves and
    applies fine, the private one 403s. So:

    • only ruleset bypass actors that are private Apps are affected, which is
      presumably why this has gone unreported for so long;
    • there is a workaround, contrary to the severity:high triage note — publish
      the App. It is frequently unacceptable (a public App discloses its name and
      permission set and can be installed by anyone, while the owner keeps the
      private key), but it exists and is worth recording;
    • a fix only has to cover the private-App case.

    Fix 2 is nearly free: otterdog already builds the exact mapping

    GET /orgs/{org}/installations is not a new call on a new credential — the
    read path already makes it on every run, with the installation token, and the
    issue's own observation that "importing, plan and drift detection all work" is
    the proof that the token can read it.

    models/github_organization.py (current main):

    app_installations = {
        str(installation["app_id"]): installation["app_slug"]
        for installation in await provider.rest_api.org.get_app_installations(github_id)
    }

    …and the bypass-actor read path then does actor["app_slug"] = app_installations[actor_id].
    That dict is keyed str(app_id) -> app_slug, and app_id is exactly the
    actor_id the rulesets API wants. Fix 2 is inverting a dict otterdog already
    has, for private and public Apps alike
    , with GET /apps/{app_slug} left as the
    fallback for an App that is not an installation on the org.

    Fix 1 alone would trade a hard failure for a permanent diff

    Accepting a digits-only bypass actor (as #695 did for status checks) is not
    sufficient on its own. The read path resolves a live bypass actor back to a
    slug through the map above, so config holding 12345 would be compared
    against a live my-app on every plan — a standing phantom diff, for a value the
    user cannot express any other way. Fix 1 would need comparison normalization on
    the read side as well; fix 2 keeps config in slugs and never raises the question.

    (Fix 3 is a worthwhile stopgap and we run it downstream — a plan-time probe of
    every declared App-shaped actor with the credential apply will use, so the
    failure surfaces at review instead of mid-apply. It does not remove the need for
    1 or 2: it only moves the discovery earlier.)

    Happy to open a PR for fix 2 if that direction is acceptable.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    ai-triagedConfidently classified by the automated triage agent; remove this label to force re-triagecomponent:configurationotterdog.json / jsonnet configuration modelcomponent:github-apiGitHub API client / provider layereffort:smallSmall, well-scoped fixpriority:p1Next up, high priorityseverity:highMajor functionality broken, no reasonable workaroundtype:bugSomething isn't working

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions