Skip to content

feat(service-accounts): put service accounts behind the ENABLE_SERVICE_ACCOUNTS feature flag - #1088

Merged
henriquehirako merged 6 commits into
feat/ohe-3713-service-accounts-uifrom
openhands/ohe-3795
Nov 3, 2026
Merged

henriquehirako merged 6 commits into
feat/ohe-3713-service-accounts-uifrom
openhands/ohe-3795

Conversation

@henriquehirako

@henriquehirako henriquehirako commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

HUMAN:

  • A human has tested these changes.

AGENT:


Why

Service accounts must stay hidden until a Super Admin turns them on. ENABLE_SERVICE_ACCOUNTS is a database feature flag that falls back to an env var of the same name (default false), the same way ENABLE_BILLING works. The switch on the Super Admin Instance page writes the flag, so no redeploy is needed.

Summary

  • Backend gate. ENABLE_SERVICE_ACCOUNTS is now in _ENV_FLAG_DEFAULTS. The new validate_service_accounts_enabled() dependency on service_accounts_router returns 403 on every /api/organizations/{org_id}/service-accounts* route while the flag is off. It is a copy of validate_billing_enabled, and the 403 detail names the flag and the env var.
  • Web-client config. WebClientFeatureFlags.enable_service_accounts takes the env value as a fallback in _get_feature_flags. get_web_client_config then resolves it again with _resolve_flag('ENABLE_SERVICE_ACCOUNTS', ...), so a database row wins over the env var.
  • Frontend.
    • isSettingsPageHidden hides /settings/service-accounts (both the route loader and the nav entry) unless enable_service_accounts is true.
    • adminService gets createFeatureFlag and updateFeatureFlag.
    • New hook useSetServiceAccountsEnabled. It sends PATCH, and if that returns 404 it sends POST. On success it writes the saved value into the web-client config query and does not refetch it, because each server process caches flags for 5 s and a refetch could return the old value.
    • The Instance page gets a SettingsSwitch with testId="super-admin-instance-enable-service-accounts". The switch is disabled while a save is in progress.
    • New i18n key SUPER_ADMIN$INSTANCE_ENABLE_SERVICE_ACCOUNTS ("Enable Service Accounts (beta)").
Super Admin switch ──PATCH/POST /api/admin/feature-flags──▶ feature_flags row (ENABLE_SERVICE_ACCOUNTS)
                                                               │  (db row wins, else env var, else false)
                         ┌─────────────────────────────────────┴──────────────────────┐
     validate_service_accounts_enabled (403 when off)            GET /api/v1/web-client/config
     on every service_accounts_router route                       feature_flags.enable_service_accounts
                                                                   └▶ isSettingsPageHidden / settings nav

How each acceptance criterion is met

Criterion Where it is met Test
Flag off: every service_accounts_router route returns 403 Router-level Depends(validate_service_accounts_enabled) test_routes_return_403_when_flag_is_off checks all 10 routes. test_every_route_is_gated compares the tested routes with the router's by method and path, so a new route fails until it is added.
Flag off: /settings/service-accounts and its nav entry are hidden isSettingsPageHidden settings-utils.test.ts, use-settings-nav-items.test.tsx ("hides service accounts while enable_service_accounts is off"), service-accounts.test.tsx ("redirects an owner away while enable_service_accounts is off")
Flag on (env var or database row): no change from today. A database row wins over the env var. feature_flag_service.resolve test_routes_work_when_env_var_is_on[true/1], test_routes_work_when_database_row_is_on, test_database_row_wins_over_env_var. These run the real flag service against PostgreSQL. All existing route tests still pass with the gate open.
GET /api/v1/web-client/config returns feature_flags.enable_service_accounts, from the database row first, then the env var default_web_client_config_injector.py TestEnableServiceAccounts (real database): test_env_var_when_no_row, test_database_row_wins, test_toggle_takes_effect_without_restart
The Instance page switch has the label "Enable Service Accounts (beta)", shows the resolved value and writes the database flag SuperAdminInstance + useSetServiceAccountsEnabled super-admin-instance.test.tsx: label and value shown, PATCH sent on toggle, POST sent when PATCH returns 404, switch disabled while saving
Turning the flag on or off needs no redeploy Database flag. The admin endpoints invalidate the cache in their own process, and other processes see the change within the 5 s cache TTL. Local run below, plus test_toggle_takes_effect_without_restart

Issue Number

OHE-3795

How to Test

These are the commands I ran on this branch, with their results:

  • uv run pre-commit run --config ./dev_config/python/.pre-commit-config.yaml (on the changed files): ruff, ruff format and mypy all passed.
  • uv run pytest -n 4 --dist worksteal ./tests/unit: 9027 passed.
  • uv run pytest tests/unit/server/routes/test_service_accounts.py tests/unit/app_server/test_default_web_client_config_injector.py tests/unit/server/services/test_feature_flag_service.py tests/unit/test_billing.py: all passed.
  • cd frontend && npm run lint:fix && npm run lint (this includes tsc): passed, with 0 errors.
  • cd frontend && npm run build: passed.
  • cd frontend && npx vitest run: 3260 passed across 360 test files (2 skipped), with 0 failures.
    • Vitest also reported a few "Unhandled Rejection: ProgressEvent is not defined" errors from MSW's XHR interceptor during test teardown.
    • These already happen on the base branch. A full run of feat/ohe-3713-service-accounts-ui gave 3 such errors (from app-settings, agent-settings and interactive-chat-box).
    • On this branch, one run attributed a 4th error to service-accounts.test.tsx. That file passed with no error when run on its own, twice.
  • Red first: I removed the implementation and the new tests failed: 11 backend gate tests and 9 frontend tests. With the implementation back, they pass.

Local run (switch on → off → on, one server process, no restart)

Setup:

  • PostgreSQL from make local-db, migrated to head.
  • The real feature_flag_router, service_accounts_router, FeatureFlagService and DefaultWebClientConfigInjector.
  • Keycloak was not available in the sandbox, so I stubbed only the identity and role lookups, to act as a Super Admin who owns the org.
  • The calls are the same ones the Instance switch sends:
--- initial: no env var, no database row
GET /api/v1/web-client/config        -> {"feature_flags":{"enable_service_accounts":false}}
GET /api/organizations/{org}/service-accounts -> 403 {"detail":"Service accounts are disabled in this environment. Enable the ENABLE_SERVICE_ACCOUNTS feature flag (or the ENABLE_SERVICE_ACCOUNTS environment variable) to use them."}
PATCH /api/admin/feature-flags/ENABLE_SERVICE_ACCOUNTS {"enabled":true} -> {"detail":"Flag not found"} [404]
POST /api/admin/feature-flags {"key":"ENABLE_SERVICE_ACCOUNTS","description":"Enable Service Accounts (beta)","enabled":true} -> {...,"enabled":true,"rules":[]} [201]
--- after switch ON (no restart)
GET /api/v1/web-client/config        -> {"feature_flags":{"enable_service_accounts":true}}
GET /api/organizations/{org}/service-accounts -> 200 {"items":[],"next_page_id":null}
PATCH /api/admin/feature-flags/ENABLE_SERVICE_ACCOUNTS {"enabled":false} -> {...,"enabled":false} [200]
--- after switch OFF (no restart)
GET /api/v1/web-client/config        -> {"feature_flags":{"enable_service_accounts":false}}
GET /api/organizations/{org}/service-accounts -> 403 {...}
PATCH /api/admin/feature-flags/ENABLE_SERVICE_ACCOUNTS {"enabled":true} -> {...,"enabled":true} [200]
--- after switch ON again (no restart)
GET /api/v1/web-client/config        -> {"feature_flags":{"enable_service_accounts":true}}
GET /api/organizations/{org}/service-accounts -> 200 {"items":[],"next_page_id":null}

In the frontend, the nav entry and the route follow feature_flags.enable_service_accounts through isSettingsPageHidden. The mutation writes the saved value into the config query, so the page picks up the change without a reload.

I also tested the UI end to end in a browser, on a local docker compose stack built from this branch. The stack had a fresh database and ENABLE_SERVICE_ACCOUNTS was not set. I logged in as a Super Admin and did not restart between steps. See the screenshots below.

Video/Screenshots

1. Switch off (default). The settings nav has no Service Accounts entry, and the API returns 403.

1a-instance-off 1b-settings-nav-off

2. Switch on. PATCH returns 404, then POST returns 201. The entry appears and /settings/service-accounts loads (API 200).

2a-instance-on 2b-settings-nav-on 2c-service-accounts-page

3. Switch off again. PATCH returns 200. The entry is hidden, and /settings/service-accounts redirects to /settings/user (API 403).

3a-instance-off-again 3b-settings-nav-off-again 3c-direct-url-redirect

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

  • Default is off. Installs that use service accounts today must turn the switch on, or set ENABLE_SERVICE_ACCOUNTS=true.
  • Env value parsing. The env fallback in _get_feature_flags uses _env_flag_enabled (accepts true or 1). The ticket text used == 'true', but the repo's AGENTS.md rule requires accepting both values. This also matches what the backend gate accepts.
  • No config refetch after a save. The ticket asked to invalidate the config query. A refetch can reach a server process whose flag cache still has the old value, so the switch keeps the PATCH or POST response instead.
  • Not gated: API key hardening, the identity model, migrations, guards and the automation service, as the ticket says.
  • Known limits (from the ticket):
    • Turning the flag off does not stop automations that service accounts already own.
    • A direct automation API call can still transfer an automation to an existing service account.
    • The flag cache TTL is 5 s per process.
    • Targeting rules on the flag row are not supported. The gate and the web-client config resolve the flag with no user or org, so an include rule turns it off for everyone and an exclude rule is ignored. ENABLE_BILLING works the same way.
    • While the flag is off, the service account routes (including Disable) return 403. To stop a service account then, a Super Admin disables it from the user directory, or turns the flag on first.
    • While the flag is off, org budgets still list service account rows, and admins can still set their budget. This keeps budget control over automations that keep running.
  • Test helper changes. The shared createMockWebClientConfig default is unchanged (enable_service_accounts is absent, so the flag reads as off). The existing service-account route tests and nav tests now turn the flag on explicitly.

This PR was created by an AI agent (OpenHands) on behalf of the ticket author.

@henriquehirako can click here to continue refining the PR


Enterprise server image for this PR:

ghcr.io/openhands/enterprise-server:sha-32fab92

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  openhands/app_server/web_client
  default_web_client_config_injector.py
  web_client_models.py
  server/routes
  service_accounts.py
  server/services
  feature_flag_service.py
Project Total  

This report was generated by python-coverage-comment-action

@henriquehirako henriquehirako changed the title [OHE-3795] Put service accounts behind the ENABLE_SERVICE_ACCOUNTS feature flag (enterprise) feat(service-accounts): put service accounts behind the ENABLE_SERVICE_ACCOUNTS feature flag Oct 30, 2026
@github-actions github-actions Bot added the type: feat A new feature label Oct 30, 2026
@all-hands-bot

all-hands-bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Review posted for a7e21b121c8c

Verdict: ✅ APPROVED. See the review.

This is an automated status.

@henriquehirako
henriquehirako marked this pull request as ready for review November 2, 2026 23:20
@henriquehirako
henriquehirako requested review from all-hands-bot and removed request for all-hands-bot November 3, 2026 01:58

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Code: I found no correctness or security defects on a7e21b1.

  • The router-level Depends(validate_service_accounts_enabled) gates every service_accounts_router route. test_every_route_is_gated keeps that true for routes added later.
  • ENABLE_SERVICE_ACCOUNTS resolves the same way as ENABLE_BILLING: the DB row first, then the env var (true/1), then false.
  • The admin POST/PATCH endpoints exist with the payloads the new hook sends. They require MANAGE_FEATURE_FLAGS (super admin only) and invalidate the process cache.
  • In the frontend, only the /settings/service-accounts page calls the gated API. Hiding that page through isSettingsPageHidden therefore leaves no other UI path that would hit the 403.

All CI checks on this head are green. I re-ran the focused suites locally and they pass:

  • Backend (real PostgreSQL): test_service_accounts.py, test_default_web_client_config_injector.py, test_feature_flag_service.py and test_billing.py, 261 passed.
  • Frontend: super-admin-instance, use-settings-nav-items, service-accounts and settings-utils, 84 passed.

Blocking: live UI evidence is missing. This PR adds a new user-visible control (the "Enable Service Accounts (beta)" switch on the Super Admin Instance page). It also changes what the settings nav and the /settings/service-accounts route show. The PR template asks for a video or screenshots for GUI features, and the ticket's Verify step asks the same: as a Super Admin, flip the switch and show that the nav entry and the API follow it with no restart. The PR has "Video/Screenshots: None" and says the UI was not tested end to end in a browser. The curl log and the unit tests support the review, but they cannot replace that capture. Please attach screenshots or a screen recording from a running app (not a mock build) that show:

  1. The Instance page with the switch off, and the Service Accounts entry missing from the settings nav.
  2. The switch turned on, the Service Accounts entry appearing, and /settings/service-accounts loading.
  3. The switch turned off again, with the entry hidden and the page redirecting.

🔄 CHANGES REQUESTED

@henriquehirako

Copy link
Copy Markdown
Contributor Author

Added the live UI evidence to the PR description under "Video/Screenshots". I tested on a local docker compose stack built from this branch, with a fresh database and ENABLE_SERVICE_ACCOUNTS not set, logged in as a Super Admin, with no restart between steps. The screenshots show the switch off (no nav entry, API 403), on (PATCH 404 → POST 201, entry shown, page loads, API 200) and off again (PATCH 200, entry hidden, direct URL redirects to /settings/user, API 403).

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

The head has not changed since my last review. The only open item was the blocking request for live UI evidence, and it is now resolved. The PR description has screenshots from a running stack built from this branch, logged in as a real Super Admin:

  • The Instance page shows the "Enable Service Accounts (beta)" switch off, and then on.
  • With the switch off, the settings nav has no Service Accounts entry.
  • With the switch on, the entry appears and /settings/service-accounts loads ("No service accounts yet").

That covers the production path for the new switch and for the nav and route gating.

Non-blocking observation: the two "switch off again" images (3a and 3b) are byte-identical to the "switch off" images (1a and 1b). The same render of the same state can produce identical files, so they do show the entry hidden again. They do not capture the direct-URL redirect to /settings/user. service-accounts.test.tsx ("redirects an owner away while enable_service_accounts is off") covers that redirect, and the backend 403 is covered by the gate tests, so this does not block.

No new findings. All checks on this head are green.

✅ APPROVED

openhands-agent and others added 6 commits November 2, 2026 21:45
Gate every service_accounts_router route on the ENABLE_SERVICE_ACCOUNTS
database flag (env var fallback, default off), expose it in the web-client
config, hide /settings/service-accounts while it is off, and add a Super
Admin Instance switch that writes the database flag.

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
…nfig refetch returns

The switch was enabled again with the old value while the config refetched.
It now shows the saved value at once and stays disabled until the refetch
finishes.
…hing the config

The switch wrote the saved value, then refetched the web-client config. Each server process caches flags for 5 s, so with more than one replica the refetch could return the old value and show it for the config's 5 minute stale time. The switch now shows the PATCH or POST response and does not refetch. The test for the held refetch is replaced by one where a stale read would come back.
@henriquehirako
henriquehirako merged commit 2f9f938 into main Nov 3, 2026
23 checks passed
@henriquehirako
henriquehirako deleted the openhands/ohe-3795 branch November 3, 2026 03:09
@openhands-release-bot openhands-release-bot Bot added the released: 1.84.0 Shipped in 1.84.0 label Nov 3, 2026
@openhands-release-bot

Copy link
Copy Markdown
Contributor

🚀 Released in 1.84.0.

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

Labels

released: 1.84.0 Shipped in 1.84.0 type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants