Skip to content

feat(api-keys): give API keys custom roles, as teams have them - #5211

Merged
simlarsen merged 17 commits into
masterfrom
claude/api-key-custom-roles
Oct 23, 2026
Merged

simlarsen merged 17 commits into
masterfrom
claude/api-key-custom-roles

Conversation

@simlarsen

@simlarsen simlarsen commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

What

API keys can now be given custom roles, the way teams are. The maintainer asked: "We can create custom roles and add that to teams, but we cannot add that to API keys. Can we please add functionality where we can add custom roles to API keys as well?"

This is the team mechanism reused, not a second one:

  • A new ApiKeyCustomRole row says "this key has this role". It sits beside TeamCustomRole and has the CRUD route /api-key-custom-role.
  • When a key is given a role, the key gets an ApiKeyPermission copy of each of the role's permissions, with the same labels. CustomRoleSync creates these copies and keeps them in step with the role, as it does for teams. Each copy is marked with customRoleId, apiKeyCustomRoleId and customRolePermissionId, and the database deletes it when the role is deleted, taken away, or loses that permission (ON DELETE CASCADE).
  • Every check that reads what a key may do already reads ApiKeyPermission. So a key's effective permissions include its roles with nothing new to get wrong: labels and scoping behave as for the key's own permissions.
  • A permission that the role limits to resources someone owns gives the key nothing, because a key owns nothing. The docs already said keys don't support the Owned scope, and wherever the UI counts a role's permissions on a key it says how many apply ("2 of its 3 permissions apply to this key").

Safety

  • No more than the role grants. Giving a role is checked as giving each of its key-applicable permissions to the key directly, with their labels. It goes through the key's own grant ceiling (ApiKeyPermissionService.assertCallerCanGrantPermission). A property test shows this ceiling is never looser than the team ceiling for a role's copies.
  • Who may give a role. Only someone who may change what a key can do (Project Owner, Project Admin, Edit API Key Permissions) and who can read the role. Anyone else is refused with a message that says why.
  • Removal is immediate.
    • The key's permission cache is keyed by a generation in CacheGenerations.
    • These all bump that generation: giving or taking a role, adding, changing or removing a permission of the role, and deleting the role. Every server then drops what it had read about the key.
    • A deleted role leaves no rows behind, because of the cascades.
  • Copies are read-only. The API refuses any non-root update or delete of a copy. Change the role instead, or take it away from the key.
  • Audit log. Each grant is logged under the key, named by the role (@EnableAuditLog).
  • If the copies can't be made, the role is taken back off the key and the request is refused.

Where it shows

  • A key's page: a new Custom Roles card above Permissions. Each role links to its page and shows how many of its permissions apply to the key. Add Custom Role offers only the roles the key doesn't have, and Remove says what the key keeps. The key's own Permissions list never shows the copies.
  • A role's page: API Keys with this Role, below the teams, with Give to an API Key and Remove. The line about who a change reaches, and the Delete dialog, count keys too. Someone who may not read keys sees neither.
  • Create API Key: the project's custom roles appear under Access, after the built-in roles. Only roles that would give the key something, and that the user could give, are listed.
  • Roles page and delete wording now mention API keys.
  • API reference and Terraform: generated from the model. This adds the oneuptime_api_key_custom_role resource (changing its key or role replaces it). oneuptime_api_key_permission can read the three copy columns but not set them.
  • Docs: permissions/index.md and configuration/audit-logs.md, in all 17 languages.
  • Dashboard strings: 34 new strings, in all 17 locales.

Migrations

  • 1806500000000-AddApiKeyCustomRoles: the table with its indexes and FKs, plus the three columns on ApiKeyPermission.
  • 1806510000000-AddApiKeyPermissionCustomRoleLinks: the copy columns' indexes and FKs, built with OnlineDdl outside a transaction.
  • Both come after master's latest migrations. The schema drift check is clean.

Tests

  • Copy plan: ApiKeyRoleCopyPlan.test.ts (seeded property tests).
  • Sync, hooks and ceilings: CustomRoleSyncApiKeys.test.ts, ApiKeyCustomRoleService.test.ts, ApiKeyPermissionRoleCopies.test.ts and ApiKeyCeilingWithinTeamCeiling.test.ts.
  • Against a migrated Postgres: ApiKeyCustomRolesPostgres.test.ts, 20 tests, added to the postgres-schema-drift workflow. It covers a role alone, a role beside the key's own permissions, label-scoped roles, Owned-scope permissions, a key's own block, two roles holding the same permission, cache invalidation on every kind of change, and refusals.
  • API and Terraform: ApiKeyCustomRoleApiAndTerraform.test.ts, which reads the real OpenAPI spec and the real provider parser.
  • Dashboard:
    • ApiKeyViewCustomRoles.test.tsx and RoleViewApiKeys.test.tsx;
    • Create API Key cases in ApiKeysCreateAccess.test.tsx;
    • CustomRoleRelationSelect.test.ts, which pins a relation-select bug on CustomRole.permissionCount that was fixed here;
    • the helper tests.
  • Browser, offline: packages/E2E/CustomRoles/ApiKeyCustomRoles.spec.ts, 12 specs on the custom roles fixture (which now also mounts SettingsRoutes), at 1280 and 390 px and in dark mode.

Verification (local)

  • Common: tsc and compile-tests pass. The guards, locale suites and this branch's suites pass, including with ee/ moved aside.
  • Dashboard and App: tsc passes. Tests/Dashboard, the App root suites and Tests/FeatureSet/Docs all pass except CliDocsFacts. That suite fails on master too (master's DatabaseService sort change) and none of its files differ from master here.
  • Postgres: all seven Postgres suites pass on a database migrated from scratch.
  • Browser: the custom roles suite passes, 70 specs in 3.5 minutes.
  • Strings and docs: i18n:check, i18n:validate and docs:check-anchors pass.

UI review

Looked at: every surface this PR adds or changes, rendered from the real pages through the offline custom roles fixture in headless Chromium (Inter loaded), at 1280 and 390 px, light and dark, in English, German and Japanese, with long names and empty, one-row and every-row states:

  • a key's page: the Custom Roles card (full, empty, every role given), Add Custom Role, Remove, the bulk Remove;
  • a role's page: Permissions with its reach line, Teams with this Role, API Keys with this Role, Give to an API Key (open list, last key, no key left, no key at all), Remove, Delete;
  • Create API Key with custom roles under Access; the Roles page and the team pages beside them.

What was not great, and what changed:

  • "2 of its 3 permissions apply to this key" never said why. The reason was only in the Add dialog, in words ("limited to resources someone owns") that match nothing on the role's page. The count now has an (i), reachable by mouse, keyboard and screen reader, that says it in the words of the role's Scope column: Permissions scoped to "Only resources they own" don't apply to an API key: a key owns nothing.
  • A role's API Keys card was wrong for a role with an owned-only permission. It said "These API keys can do everything this role allows" above a role whose Alert Member is "Only resources they own". It now says These API keys get 2 of this role's 3 permissions: those scoped to "Only resources they own" don't apply to a key. When every permission is owned-only, it says none applies. It follows the role's permissions as they change: delete or rescope one and the card updates.
  • The dialogs said the exception even when it did not apply. Add Custom Role and Give to an API Key now name it only when a role offered (or the role given) has an owned-only permission. In most projects none does, so the dialog is one short sentence.
  • Give to an API Key could lead to a dead end. In a project with no API key, or once every key had the role, it opened a dialog whose list said "No options." It is now off, with the reason on hover. A project with no key gets an empty card that says so and links to the API Keys page.
  • Phone cards repeated their column's name. On a phone, "Role" sat over every role on a key's card, and "API Key" and "Team" over every row on a role's page. These one-column tables now leave the name out on phones, for the team's own Custom Roles card too, so sibling pages match. Screen readers still hear it. ModelTable columns now type hideTitleOnMobile, which they already passed through to the table.
  • The API reference and Terraform description of ApiKeyCustomRole now state the Owned exception (the builder left it out).
  • CI time. The offline custom roles suite now takes 4.1 minutes with its bundle locally, about 1.2 more than on master. Group 7 took 21.5 minutes on master's last run, so Archivable Values moved to group 9, which took 18.9 to 19.4.
  • Smaller fixes:
    • A hard-coded English fallback in the no-key link, which DashboardComponentsAToLHardcodedGuard flags.
    • A test pattern (/\d+ API keys?/) that the ReDoS checker found quadratic, now anchored at a word edge.
    • Two over-long comment lines and an orphaned comment.

Strings: 13 new sentences (one with its _one form) in all 17 locales, translated, with each language's own quote marks around the scope's name. The three builder sentences they replace are removed. i18n:check reports no translation lost.

Tests added or changed:

  • CustomRoleCopy.test.ts: the reason, the API Keys card in every case (unknown, none, some and all owned, counts out of range), both dialog descriptions, German. Every new sentence is in en.json.
  • CustomRoleOptions.test.ts: loadCustomRoleGrants reads one role when given a customRoleId; getApiKeyRoleCoverage.
  • ApiKeyViewCustomRoles.test.tsx: the (i) only on a partial role, its accessible name and tooltip, an all-owned role, and the Add dialog with and without the exception.
  • RoleViewApiKeys.test.tsx: the card and Give dialog for partial and all-owned roles, one role's permissions read, the card following a change of permissions, a project with no key (empty card, link, Give off with its reason), and every key having the role.
  • ApiKeyCustomRoles.spec.ts (offline browser), 5 new and 4 updated specs:
    • the (i) on hover and keyboard focus;
    • Add Custom Role without the exception;
    • a role with nothing owned-only;
    • deleting the owned-only permission updates the card;
    • ?keys=none, with the link working;
    • Give off once every key has it;
    • no column name on phone cards.
  • The fixture gains ?keys=none. ApiKeyCustomRoleApiAndTerraform.test.ts pins the new description.

Run locally:

  • Common: the changed suites, all Locale suites, and npx jest Guard plus the guard-like suites (168 suites).
  • App: the root-level suites, the Dashboard guard, locale, wiring and description suites, CustomRolesDocs and PermissionsAndSelfHostedDocsFacts (191 suites). The changed suites also ran with ee/ moved aside.
  • Repo: Tests/Ops (64 suites).
  • Browser: the whole offline custom roles suite, 75 specs.
  • Type checks: Common tsc and compile-tests, App compile, Dashboard tsc and E2E tsc.
  • Other checks: i18n:check, prettier and eslint on every changed code file, the --listFiles package check, and the ReDoS checker, which is clean.
  • The 34 docs .md files this branch changes are not prettier-clean, and neither are master's copies of them.

Left as it was, on purpose:

  • The key's Custom Roles card keeps its full empty state and its place above Permissions. A team's Permissions page does the same, and a key keeps looking like a team.
  • A disabled card button (ButtonStyleType.NORMAL) still looks enabled, across the whole product. The tooltip says why it is off; restyling the shared Button is a change of its own.

🤖 Generated with Claude Code

valeriamustafaeva and others added 10 commits October 23, 2026 13:02
…mRole, copies kept by CustomRoleSync

An API key can be given a project's custom roles, as a team can. The key holds
a copy of each of the role's permissions as an ordinary ApiKeyPermission row
marked with the role (customRoleId / apiKeyCustomRoleId /
customRolePermissionId), kept equal to the role by the same CustomRoleSync that
keeps teams' copies - so the API key auth path reads them with no change. A
permission the role limits to what its holder owns gives a key nothing (a key
owns nothing), so no copy of it is made.

- ApiKeyCustomRole model (+ migration and the online-DDL links migration).
- ApiKeyCustomRoleService: key and role in the project, the giver must read the
  role, and every permission the key would hold is held to the key grant ceiling.
- ApiKeyPermissionService: copies are changed only through their role; own-row
  uniqueness; cached key permissions are now kept per project generation
  (CacheGenerations), so a change reaches every server within a second.
- CustomRole permission counts are readable through a relation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ooks, copies, cache generations, ceilings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… wired into the drift workflow

ApiKeyCustomRolesPostgres runs the real services, sync, key permission cache
and permission layer: a role gives a key what the same permissions given
directly give, Owned gives a key nothing, every change reaches the key's next
request and starts a new generation, refusals, cascades. The clone suites whose
code runs a role's sync now clone ApiKeyCustomRole too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e and Create API Key

- An API key's page lists its custom roles above its own permissions, with Add
  Custom Role and Remove, and says how many of a role's permissions apply to the
  key when some are limited to what someone owns.
- A role's page lists the API keys given it (Give to an API Key, Remove), and its
  reach and delete sentences count the keys.
- Create API Key offers the custom roles the user could give under Access.
- The key's own permissions table no longer lists a role's copies.
- Delete consequences, the Roles page and the docs (17 languages) say roles go
  to teams and API keys; the audit log page lists API Key Custom Role.
- Every new string in the 16 Dashboard locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ate API Key

- ApiKeyViewCustomRoles: the key's Custom Roles card - hidden in a project
  with no role, each role a link with how many of its permissions apply
  to the key, Add Custom Role offering only roles the key lacks and
  sending one ApiKeyCustomRole, off (with the reason) when the key has
  every role or the reader may not change keys, Remove's wording, and the
  key's own Permissions card never listing a role's copies.
- RoleViewApiKeys: the role's API Keys card, the reach sentence and the
  Delete dialog counting keys, Give to an API Key offering only keys
  without the role, Remove's wording, and nothing about keys for someone
  who may not read them.
- ApiKeysCreateAccess: custom roles under Access - a role giving a key
  nothing is not offered, owned-scope permissions are not weighed, a
  picked role is given once the key exists, a refusal leaves the key.
- CustomRoleRelationSelect: the cards' relation selects (permissionCount
  on TeamCustomRole and ApiKeyCustomRole, apiKey name) pass the relation
  read check; unflagged columns are still refused.
- RoleAccess, CustomRoleOptions, CustomRoleCopy, GrantablePermission:
  the key helpers and copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…390 pixels

The custom roles fixture now also mounts the real SettingsRoutes, with two
keys: Deploy bot (Team Lead and Incident Helper, Workflow Viewer of its own)
and Terraform (no role). ApiKeyCustomRoles.spec.ts drives a key's Custom
Roles card, Add Custom Role and Remove, the role's API Keys card with Give to
an API Key, the reach and Delete counting keys, Create API Key with a custom
role, a reader who may not read keys, a phone with long names, and dark mode.

CustomRoles.spec.ts follows the new Roles card and delete wording, and a
role no team or key has. The custom roles suite takes 3.5 minutes with its
bundle locally (70 specs); the CI group note says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The key's Custom Roles card draws the menu's word for Roles without a
  hard-coded English fallback, as the A-to-L hard-coded string guard asks.
- TeamCreateAccessGuard pins the API Keys page as it pins the Teams page:
  the custom roles after the built-in ones, and a custom role given through
  the helper Add Custom Role uses.
- The Postgres suite orders a key's own row before a role's copy of the same
  permission, which random ids used to order by chance.
- Test type fixes the Common compile-tests job asks for, and Prettier on
  the files this branch added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ece of their path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…om-roles

Master took 1806300000000 (AddOwnerRuleCreatorTeams) and 1806400000000
(WidenDetectionRuleName), so this branch's migrations move after them:
AddApiKeyCustomRoles1806500000000 and
AddApiKeyPermissionCustomRoleLinks1806510000000. The Dashboard locales are
master's, with this branch's 34 strings extracted again and their
translations applied in all 16 languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d the Terraform provider

The real OpenAPI spec and provider parser: /api-key-custom-role creates, lists, reads and deletes a grant naming a key and a role, never changes one in place; oneuptime_api_key_custom_role replaces a grant whose key or role changes; a key's copies of a role's permissions are read and never written, in the API and in oneuptime_api_key_permission.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simlarsen

simlarsen commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

valeriamustafaeva and others added 7 commits October 23, 2026 16:49
…e's keys get

UI review of custom roles on API keys.

- A key's Custom Roles card: "2 of its 3 permissions apply to this key" now
  has an (i) that says why, in the words the role's Scope column uses:
  Permissions scoped to "Only resources they own" don't apply to an API
  key: a key owns nothing. Add Custom Role names that exception only when
  a role it offers has such a permission.
- A role's API Keys card said "These API keys can do everything this role
  allows" even for a role with an owned-only permission. It now says how
  many of the role's permissions the keys get, or that none apply, and
  follows a change of the role's permissions. Give to an API Key says the
  exception only when the role has such a permission.
- Give to an API Key is off, with the reason, when every key has the role
  or the project has no key yet; the empty card then links to the API Keys
  page instead of offering a dialog with an empty "No options." list.
- Phone cards of the one-column role, key and team tables leave out the
  column's title over every card (hideTitleOnMobile, now typed on
  ModelTable columns too).
- The ApiKeyCustomRole table description (API reference, Terraform) says
  the Owned exception.
- 13 new strings in 17 locales; the three they replace are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 17 specs

The custom roles offline suite now takes 4.1 minutes with its bundle
locally, about 1.2 more than on master, after the specs for custom roles on
API keys. Group 7 took 21.5 minutes on master's last run without them, so
it would run past the 22 the groups are kept to. Archivable Values (about
1.5 minutes, Chromium only) moves to group 9, which took 18.9 to 19.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r count pattern in its test

DashboardComponentsAToLHardcodedGuard counts the API Keys link's English
fallback as a hard-coded string; the role page test's /\d+ API keys?/
retried from every digit of a long number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lains

A screen reader heard "About Permissions that apply"; it now hears "About
the permissions that apply to this key", worded in each language for its
own About sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simlarsen
simlarsen merged commit 632e36f into master Oct 23, 2026
1 of 58 checks passed
@simlarsen
simlarsen deleted the claude/api-key-custom-roles branch October 23, 2026 17:28
simlarsen pushed a commit that referenced this pull request Oct 23, 2026
Both sides appended keys at the end of the 17 Dashboard locale files (#5211's
API key custom roles, and this branch's Overview card): kept both, this
branch's first, master's values on any clash; i18n:extract stable.
simlarsen pushed a commit that referenced this pull request Oct 23, 2026
…type, and workflow steps cannot give one

Common Test has failed on master since #5211 merged (it merged without a
finished run), in two guards that read every model:

- AuditLogModelConfiguration: ApiKeyCustomRole filed its audit entries
  under the root type "ApiKey", but an API key's own entries are filed
  under its singular name, "API Key", and no audited model is called
  "ApiKey". The project audit log looks a root up by that type to link
  an entry to its page, so a role given to or taken from a key linked
  nowhere. The pointer now names "API Key", as TeamCustomRole's names
  "Team". 14.0.87 does not have #5211, so no stored entry has the old
  type.
- WorkflowStepCredentialSettings: its survey pins every record a
  workflow step can write that names a setting holding credentials, so
  each new one is looked at. ApiKeyCustomRole.apiKey names an API key,
  like ApiKeyPermission.apiKey; the existing rule refuses it to a step
  and lets a person who may read API keys set it. It is pinned now, and
  the refusal and the person's case run for it too.

Both suites pass (146), and so do the custom role suites (235), the
audit root backfill (10) and ee's audit log recorder suites (128).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant