ADR 0017: Autonomous Deployment Principals¶
Date: 2026-09-02
Status¶
Accepted
The full design lives in the dev-environment repository's
docs/autonomous-deployment-principals-functional-spec.md. This ADR records
the imbi-api-side authorization decision: how a principal with no acting
user reaches a GitHub-backed deployment capability, and what bounds it.
Related: ADR 0016. That ADR
frames the general problem of work performed without an interactive human;
this is the service-account half. The delegated run-as-user half
(RFC 8693 token exchange, scheduler:impersonate) remains unimplemented and
is unchanged by this decision.
Context¶
A service account presenting a client-credentials or SA API-key token could
not call any GitHub-backed deployment endpoint. The motivating consumer is a
daemon that rolls back misbehaving deployments and promotes well-behaved ones:
no human in the loop, therefore no browser, no OAuth consent, and no
IdentityConnection.
Four facts made this structural rather than a data gap:
-
The identity challenge is aimed at a browser.
attach_identitystampsactor_user_id = auth.user.id if auth.user else None;hydrate_identityraisesIdentityRequiredErrorwhen that is falsy, which becomes401plusWWW-Authenticate: Imbi-Identity. The sole purpose of that response is to make the UI render a Connect button. -
A service account cannot acquire the thing it is being asked for.
IdentityConnectionis keyed{integration_id, user_id}and is only ever written by the interactive OAuth flow under/me/identities/*. -
The demand is an accident of unification. With no explicit
identity_integration_idon the capability binding,effective_identity_integration_iddefaults to the serving Integration whenever that Integration also providesidentity. The unified GHEC Integration provides bothidentityanddeployment, so it became its own identity source. A deployment-only Integration would skip hydration entirely. -
The plugin layer was already capable.
resolve_bearerprefers a threaded-inaccess_tokenand otherwise mints an installation token fromapp_id+private_key. Every deployment call funnels through it.
The concern that prompted the work was the GHEC App's broad repository permissions. Investigation found the Imbi-side control weaker than the GitHub-side one:
project:deployment:writeis a single flat permission covering draft-notes, trigger, promote, cut, publish, and block/unblock.require_permissionis global, with no project or organization scoping.- Service accounts cannot use resource-level
CAN_ACCESSACLs — the fallback isauth.user-only. - The deployment router is mounted with no
dependencies=guard, and_project_in_orgwas called by only two endpoints. Theorg_slugpath segment was decorative. can_deploy/can_promoteare properties of theEnvironment, global to all callers. Nothing could express "this daemon may promote to staging but not production."
So a service account holding project:deployment:write reached every project
in every organization, and every environment, before any of this work.
Decision¶
The fallback is keyed on the principal, not the call site¶
_resolve_and_context previously chose between identity credentials and
service credentials on a per-caller best_effort_identity flag, which
correlated with "no acting user" only by coincidence. It now falls back
whenever auth.user is None, and _has_service_credentials accepts App
credentials on the same condition. best_effort_identity remains for callers
that want the fallback with a user present, and is now a superset of the
userless branch rather than a substitute for it.
Three independent axes bound an authenticated userless principal¶
None subsumes another, and all are additive to the capability's own permission:
-
integration:act-as-service— a new cross-cutting permission ("Use an Integration's own credential instead of the principal's identity"). A service account needsproject:deployment:writeand this, so an autonomous principal is a deliberate second grant rather than a side effect of a permission many service accounts already hold. Granted to no non-admin default role, mirroringscheduler:impersonate. The permission names the act, not the target, soconfiguration,logs,lifecycle, andanalysiscan adopt the same fallback unchanged. -
Organization membership — enforced in
_resolve_and_context, so every deployment endpoint inherits it. Service accounts already carryMEMBER_OFedges. This converts "any project in any organization" into "projects in organizations this service account was deliberately added to," and is the single highest-value control here. -
Environment.allow_autonomous— default false, enforced alongsidecan_deploy/can_promote.Environmentis the right home: it already carries deploy/promote authority in exactly this shape and inherits the existing admin UI and Cypher. Default-false means shipping this grants nothing until an operator opts an environment in. Encoding environment tiers into permission names would explode the permission set; a per-service-account environment allowlist would invent a new binding and a new UI for it.
Human callers are unaffected on all three. Their path, CAN_ACCESS ACLs
included, is unchanged.
Imbi's own workers are exempt, via an explicit marker¶
AuthContext.internal is set only by imbi.api.auth.principals.system_auth,
which mints the synthetic principals the resync sweep, backfill, and promote
watcher act under. Those hold no granted permissions and no MEMBER_OF
edges, so checking either would deny work an operator authorized when they
configured the sweep. The field defaults to False, so every real
authentication path leaves it so.
The discriminator is an explicit field rather than the best_effort_identity
call-site flag or membership in PROCESS_PRINCIPALS. best_effort_identity
is set by some genuinely external callers — the gateway's service account
reaches publish_release with it — and PROCESS_PRINCIPALS means "hide this
name from activity feeds," which is not the same question.
An autonomous principal cannot acknowledge a CI failure¶
acknowledge_ci_failure documents its meaning as "an operator who has seen
the failure and decided to ship anyway." A daemon setting it asserts
something nobody did, so it is refused with 403 — whether CI is red or
green, because the claim is false either way. This costs little: a daemon that
simply omits the flag deploys as usual, so the only deployment this blocks
is a daemon shipping a commit whose CI is failing — precisely where a human
belongs. A rollback to a genuinely good ref never needs the flag and is
unaffected.
If real rollbacks turn out to be blocked, the escape hatch is a separate
project:deployment:override-ci permission — added on evidence, not in
anticipation.
Installation tokens are minted for the operation, not the App¶
_mint posted to /app/installations/{id}/access_tokens with no body, so
every minted token carried the installation's full permission set. Each
deployment operation now declares the GitHub App permissions it needs and the
token is minted with exactly that set. The requested scope is part of the
token cache key — a correctness requirement, not an optimization: a
contents: read token served to a contents: write caller would fail at
GitHub at the worst possible moment. This bounds the pre-existing headless
sync paths at the same time.
Failures are terminal 403s with a discriminated detail.error¶
Following the existing identity_required shape:
| Condition | Status | detail.error |
|---|---|---|
Userless principal lacks integration:act-as-service |
403 | service_credential_forbidden |
| App not installed on the repo | 403 | app_not_installed |
| Environment not autonomous-enabled | 403 | environment_not_autonomous |
| Principal not a member of the organization | 403 | organization_forbidden |
Userless principal set acknowledge_ci_failure |
403 | ci_override_forbidden |
| No usable credential on the Integration | 503 | no_service_credential |
403 rather than 424/503 for the terminal states because consuming
clients retry 5xx on the assumption that it is transient, and none of these
will change on retry. Semantic precision loses to correct retry behavior; the
discriminator recovers the diagnostic information. 503 is retained for the
last row alone: a missing credential is one operator action away from being
fixed.
app_not_installed is raised by the plugin as
PluginInstallationMissing — a shared error type, so the host maps it
without importing a plugin — and is handled at the app level.
Audit records the credential as well as the principal¶
auth.principal_name already attributes the action to the service account.
What was missing is what authority carried it out. Both the Deployment node
and the operations_log row now record e.g.
github-app installation 12345678. ADR 0016 established
"{user} on behalf of {actor}" for delegation; this is the mirror image.
It goes in the audit description blob and a dedicated node property rather
than into recorded_by / performed_by: those stay bare identifiers,
because the activity feed and lookup_ops_log_performed_by match on them.
The daemon's slug is deliberately not added to PROCESS_PRINCIPALS. That
set hides names from activity feeds as mere provenance; an autonomous
rollback is exactly the kind of event that should surface as an actor.
Consequences¶
-
A service account with
project:deployment:write,integration:act-as-service, membership in the owning organization, and an opted-in target environment can drive the deployment capability with the Integration's GitHub App installation token and noIdentityConnection. -
Existing external userless callers need the new grant. The gateway's service account reaches
publish_releasetoday and will be refused withservice_credential_forbiddenuntilintegration:act-as-serviceis granted to its role. This is an upgrade step, not a regression to work around — the point of the permission is that acting with an Integration's credential is an explicit decision. -
Installation tokens are cached per requested scope, so a process that both reads and writes holds more than one token per installation. That is the intended trade: an extra mint per distinct scope per hour, against every token in flight carrying the App's full grant.
-
effective_identity_integration_idkeeps defaulting to self. With the userless fallback in place the default is harmless for machine principals, and changing it would alter behavior for human callers: a user with a working GHEC connection would silently stop using their own token and start acting as the App, losing per-user attribution on GitHub. -
Credentials stay on the
Integrationand are selected by?source=. A service-account-scoped credential store would be a fourth secret location needing its own encryption, rotation, and audit surface, for no gain. -
Installation tokens stay in an in-process cache rather than Valkey. They are bearer secrets; moving them to a shared cache to save one mint per replica per hour is a poor trade. Process-wide sharing across principals is correct — the token belongs to the App installation, not to a principal, and the authorization decision already happened upstream.
-
Organization scoping is enforced for userless principals only. That the deployment router enforces no organization membership for anyone is a pre-existing gap worth its own assessment.
-
project:deployment:writeremains coarse. Splitting it into per-action permissions would let a rollback daemon hold rollback authority without promote authority; the axes above bound the blast radius adequately without it, but the coarseness is real.