Skip to content

feat: API token management in workspace settings#10624

Open
dnplkndll wants to merge 14 commits into
hcengineering:developfrom
ledoent:feat/api-token-management
Open

feat: API token management in workspace settings#10624
dnplkndll wants to merge 14 commits into
hcengineering:developfrom
ledoent:feat/api-token-management

Conversation

@dnplkndll

@dnplkndll dnplkndll commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Workspace-scoped API tokens with a built-in REST API, managed from workspace settings. Tokens are JWTs with configurable expiry (7–365 days) and optional coarse scopes. Expiry and revocation are enforced centrally so the policy is reusable across services.

What changed

API token management (server/account/)

  • ApiToken type + apiToken collection (Mongo + Postgres). Migration V27 creates api_tokens (including the scopes TEXT[] column).
  • RPC methods: createApiToken, listApiTokens, revokeApiToken, plus owner-scoped listWorkspaceApiTokens / revokeWorkspaceApiToken.
  • Creation/revocation require AccountRole.User or higher (not guests); per-account token cap.

Centralized token verification (server-token) — addresses @aonnikov's review

  • New verifyToken() alongside decodeToken(): verifies signature, checks expiry, and — for revokable API tokens — revocation via a pluggable checker (setApiTokenRevocationChecker). Reusable by any service (transactor, blob access, …); the 60s revocation cache now lives here.
  • The account is authoritative: wrap() rejects revoked/expired API tokens, so existing methods (selectWorkspace, getWorkspaceInfo, …) naturally return 401. The redundant checkApiTokenRevoked RPC was removed; the transactor's checker reuses the existing getLoginInfoByToken.

Scope enforcement (pods/server/src/rpc.ts) — Phase 1

  • Coarse scopes read:* / write:* / delete:*, enforced per method. Scopes are parsed once per request and threaded through (no re-decode in the tx handler). No scopes = full access (legacy-compatible).

Frontend (plugins/setting-resources/)

  • ApiTokens.svelte settings page; ApiTokenCreatePopup.svelte (i18n dropdowns for scope preset + expiry); ApiDocsSection.svelte.
  • REST base URL is derived from the account-provided transactor endpoint (ws→http), per @aonnikov — not constructed from window.location.

i18n

  • API token strings added and translated across all locales (cs/de/es/fr/it/ja/pt/pt-br/ru/tr/zh). en/ru locale-parity test passes.

Docs

  • openapi.yaml describing the REST API.

Test plan

  • Unit: verifyToken (signature/expiry/revocation), scope helpers, scope enforcement, token CRUD.
  • Read-only token → find-all works, tx returns 403.
  • Read & Write → tx works, TxRemoveDoc returns 403; Full Access → all ops work.
  • Legacy token (no scopes) → full access.
  • Revoke a token → rejected within the cache TTL (~60s); account returns 401.
  • V27 migration runs on fresh and existing databases.

Ref: #10622

@huly-github-staging

Copy link
Copy Markdown

Connected to Huly®: UBERF-15850

@dnplkndll
dnplkndll force-pushed the feat/api-token-management branch 2 times, most recently from c84d786 to efdbe1b Compare March 13, 2026 01:59
@dnplkndll

Copy link
Copy Markdown
Contributor Author

Follow-up: REST API should apply sensible defaults for TxCreateDoc

While testing the token management flow end-to-end (mint token → create issues via REST), I discovered that POST /api/v1/tx/{workspace} passes TxCreateDoc transactions through with zero default-filling. Issues created this way are missing fields that the UI auto-populates, causing them to not appear in project views (only in all-issues).

Missing fields for tracker:class:Issue

When the UI creates an issue, it uses addCollection() which wraps the TxCreateDoc in a TxCollectionCUD, setting:

  • attachedTo: tracker:ids:NoParent
  • attachedToClass: tracker:class:Issue
  • collection: "subIssues"

Plus these attributes that get no server-side defaults:

  • kind → must be tracker:taskTypes:Issue (not tracker:ids:ClassingProjectType)
  • childInfo: [], parents: [], subIssues: 0, comments: 0, reports: 0
  • assignee: null, component: null, dueDate: null
  • estimation: 0, remainingTime: 0, reportedTime: 0

Impact

Issues created via bare TxCreateDoc (the natural REST usage) are invisible in project views because the Svelte live query filters on attachedTo/collection. They show up in the all-issues aggregated view only.

Possible approaches

  1. Document it — update REST API docs to show the required TxCollectionCUD wrapper pattern for issue creation
  2. Server-side defaults middleware — a new middleware in the tx pipeline that merges missing fields from the model schema for known classes (significant arch change)
  3. Higher-level REST endpoints — add POST /api/v1/create-issue/{workspace} that accepts a simplified payload and constructs the full TxCollectionCUD server-side, like the importer does

Option 3 seems most API-friendly without changing the platform's "caller provides everything" philosophy. Happy to contribute this if there's interest.

@dnplkndll
dnplkndll force-pushed the feat/api-token-management branch 3 times, most recently from c7d2bf2 to aaa8348 Compare March 16, 2026 18:45
@ArtyomSavchenko

ArtyomSavchenko commented Mar 17, 2026

Copy link
Copy Markdown
Member

Hi @dnplkndll
Thank you for your contribution.
Could you please check formatting for server/account/src/operations.ts?
The corresponding CI step is failed: https://github.com/hcengineering/platform/actions/runs/23164707679/job/67348370400?pr=10624
You can use rush format to format all project files or rushx format for a specific package.

@dnplkndll
dnplkndll force-pushed the feat/api-token-management branch 2 times, most recently from 8663ec8 to 865fd71 Compare March 17, 2026 14:42
@dnplkndll

Copy link
Copy Markdown
Contributor Author

@ArtyomSavchenko Thanks for the review! Formatting has been fixed — the issue was a prettier version mismatch (local 3.8.1 vs project's 3.6.2). New code now matches the existing codebase style with no formatting noise on existing lines.

Changes in this update

3 commits:

  1. feat: API token management — core feature (create/list/revoke tokens, UI, DB, i18n)
  2. docs: token scope roadmap — roadmap comments for future scope-based access control
  3. feat: enforce API token revocation — addresses a gap where revoked tokens remained usable until JWT expiry

What's new since last push

  • Token revocation enforcement — the transactor now checks a per-token revocation cache (60s TTL) before granting API access. Revoked tokens are rejected within ~60 seconds instead of remaining valid until expiry.
  • Owner-level workspace token visibility — workspace owners can list and revoke any member's API tokens via listWorkspaceApiTokens / revokeWorkspaceApiToken (OWNER role required).
  • Ledo instance URL removedopenapi.yaml now uses a generic https://{host} server variable.

Suggestions for future consideration

  1. Token scopes — the roadmap commit documents a proposed extra.scopes JWT field for fine-grained access control (read-only tokens, class-restricted access). This would enable safe MCP/AI agent integrations without MCP-layer changes.

  2. Higher-level REST endpoints — as noted in my earlier comment, POST /api/v1/tx requires the caller to construct full TxCollectionCUD wrappers. A POST /api/v1/create-issue style endpoint would make the API much more accessible for integrations.

  3. Revocation cache scaling — the current per-token cache works well for moderate usage. For high-traffic deployments, a Redis-backed revocation set or short-lived tokens (< 1 hour) with refresh would be more robust.

@dnplkndll
dnplkndll force-pushed the feat/api-token-management branch 2 times, most recently from c39720c to 058c7da Compare March 17, 2026 17:02
Comment thread plugins/setting-assets/lang/pt.json
Comment thread server/account/src/__tests__/apiTokenScopes.test.ts Outdated
Comment thread server/account/src/operations.ts

import { AccountRole, type MeasureContext, type PersonUuid, type WorkspaceUuid } from '@hcengineering/core'
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import { decodeTokenVerbose, generateToken } from '@hcengineering/server-token'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you please check warnings in this file?
CI formatting step is failed due to this:
https://github.com/hcengineering/platform/actions/runs/23317689234/job/67851578625?pr=10624

@ArtyomSavchenko

Copy link
Copy Markdown
Member

Hi @dnplkndll
Could you please resolve conflicts?

@dnplkndll

Copy link
Copy Markdown
Contributor Author

sure what is your opinion on the use of MCP versus api? My motivation was that I would prefer to have tool access in a controlled predicable / scriptable way with permissions on things. then you can test on a staging build out script you can repeat.
there are a few things I ran into with the api that the UI handles that should probably be refined, like defaults, too. to simplify not putting records in a bad state.

@dnplkndll
dnplkndll force-pushed the feat/api-token-management branch 2 times, most recently from f8cf105 to 4f332bb Compare March 26, 2026 21:59
@dnplkndll
dnplkndll force-pushed the feat/api-token-management branch 4 times, most recently from ebdcb02 to 370a096 Compare March 27, 2026 11:30
Comment thread plugins/setting-assets/lang/ja.json Outdated
"ApiTokenScopePreset": "Permissions",
"ApiTokenScopeReadOnly": "Read Only",
"ApiTokenScopeReadWrite": "Read & Write",
"ApiTokenScopeFullAccess": "Full Access"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you please check translations in this file and others?
Looks like many sentences were not translated and remained in English.

return window.location.origin
}

$: baseApiUrl = getBaseUrl() + '/_transactor/api/v1'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That's not correct, transactor endpoint address cannot be built in this way, it is returned by account service when user is authenticated.


async function resolveScopeLabels(): Promise<void> {
const lang = $themeStore.language
const labels = await Promise.all([

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There is no need to manually translate strings, we have dropdown version that uses i18n strings

Comment thread pods/server/src/rpc.ts Outdated
// Re-check if stale or missing
if (cached == null || now - cached.checkedAt > REVOCATION_CACHE_TTL_MS) {
try {
const revoked = await accountClient.checkApiTokenRevoked(apiTokenId)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This does not make sense.

  • User comes with revokable token that contains token Id (that is how we identify revokable token).
  • We use this token (that is potentially revoked or expired at the moment) to check whether the token is revoked

If the token is revoked, the account cannot reply anything other than 401 or 403, because the token is revoked/expired.

The account method, that returns boolean is redundant. We can use selectWorkspace or getWorkspaceInfo for this.

Additionally, I would implement token expiration / revocation logic and lower level. Maybe at the same level as decodeToken, for example we can add a new verifyToken method next to the decodeToken, that:

  1. Decodes token
  2. Checks whether the token is expired
  3. If the token is revokable, checks whether token is expired
  4. Returns decoded token

This will allow reuse token expiration / revocation logic in services to prevent accessing blobs, or other data with expired tokens.

But in order to do this, we will have to additionally set up some metadata in the server-token plugin (either address of the account endpoint, or method to verify whether the token is expired).

@aonnikov

aonnikov commented Apr 7, 2026

Copy link
Copy Markdown
Member

Thanks for the contribution
Overall the idea is great, but need to fix architectural and UI issues.

We also have a button to generate token on workspace settings page, we will need to remove it in favor of the new approach.

@dnplkndll
dnplkndll force-pushed the feat/api-token-management branch from 370a096 to cf87e38 Compare April 18, 2026 19:05
@dnplkndll
dnplkndll force-pushed the feat/api-token-management branch from e0e0743 to a3a8019 Compare May 11, 2026 22:16
dnplkndll and others added 9 commits May 15, 2026 13:46
Add UI and backend support for creating, listing, and revoking
API tokens scoped to workspaces. Includes owner-level workspace
token visibility, OpenAPI documentation, Mongo/Postgres persistence,
and i18n translations.

Signed-off-by: Don Kendall <kendall@donkendall.com>
Embed apiTokenId in JWT extra field and add a per-token revocation
cache (60s TTL) in the transactor REST handler. Revoked tokens are
now rejected within ~60 seconds instead of remaining valid until
JWT expiry.

Adds checkApiTokenRevoked account service method for the transactor
to query individual token revocation status.

Signed-off-by: Don Kendall <kendall@donkendall.com>
Add coarse-grained scope enforcement for API tokens. Tokens can now
be created with scopes ['read:*'], ['read:*','write:*'], or
['read:*','write:*','delete:*']. Existing tokens without scopes
retain full access (backward compatible).

- DB: v26 migration adds scopes TEXT[] column to api_tokens
- Types: add scopes field to ApiToken and ApiTokenInfo
- Operations: createApiToken accepts/validates/persists scopes,
  embeds in JWT via extra.scopes
- Enforcement: withSession checks scopes against method; tx handler
  additionally requires delete:* for TxRemoveDoc
- Client: createApiToken signature accepts optional scopes param
- UI: scope preset dropdown in create popup (default: Read Only),
  permissions column in token list with i18n labels
- Also fixes 3 pre-existing TS2322/TS2345 errors in operations.ts

Signed-off-by: Don Kendall <kendall@donkendall.com>
- scopes.test.ts: 8 tests for hasScope() and getRequiredScope() logic
- apiTokenScopes.test.ts: 7 tests for createApiToken scope validation
  (valid scopes, multiple scopes, no scopes backward compat, invalid
  format rejection, empty array rejection, domain-scope rejection)
  and listApiTokens scopes inclusion
- Export hasScope/getRequiredScope from rpc.ts for testability

Signed-off-by: Don Kendall <kendall@donkendall.com>
…tting

- Restrict API token creation/revocation to AccountRole.User or higher
  (guests cannot use API tokens), per reviewer suggestion
- Add 5 missing translation keys (ApiTokenPermissions, ApiTokenScopePreset,
  ApiTokenScopeReadOnly, ApiTokenScopeReadWrite, ApiTokenScopeFullAccess)
  to all non-en locale files to fix locale parity CI test
- Fix prettier formatting in apiTokenScopes.test.ts
- Rename local `extra` to `tokenExtra` in createApiToken to avoid
  shadowing the decoded token's `extra` field

Signed-off-by: Don Kendall <kendall@donkendall.com>
- rpc.ts: use system service token for checkApiTokenRevoked so the
  revocation check is not coupled to the user's potentially-revoked
  bearer token; systemAccountUuid + service:'server' ensures account
  service always accepts the call
- ApiDocsSection.svelte: derive transactor base URL from
  login.metadata.LoginEndpoint (set on auth) instead of
  window.location.origin, which is not necessarily the transactor host
- ApiTokenCreatePopup.svelte: replace manual translate() calls and
  themeStore language watch with DropdownLabelsIntl + DropdownIntlItem[],
  which handle i18n automatically; error state is now IntlString
- General.svelte: remove legacy GenerateApiToken button, handler, and
  ApiTokenPopup import in favour of the new ApiTokens settings panel

Signed-off-by: Don Kendall <kendall@donkendall.com>
Signed-off-by: Don Kendall <kendall@donkendall.com>
Signed-off-by: Don Kendall <dkendall@ledoweb.com>
Signed-off-by: Don Kendall <dkendall@ledoweb.com>
@dnplkndll
dnplkndll force-pushed the feat/api-token-management branch from a3a8019 to dfa360b Compare May 15, 2026 17:46
dnplkndll added a commit to ledoent/platform that referenced this pull request May 15, 2026
The feat/* branches got rebased onto upstream/develop to clear the
DCO + merge-conflict blockers on PRs hcengineering#10705 and hcengineering#10624. That puts
commits on those branches that depend on files only present in
develop > v0.7.423, so the prior 'merge feat/* onto v0.7.423 tag'
flow now conflicts.

Three changes:

1. ledoent/upstream-tag.txt → develop tip (fa2930d). The file now
   carries any ref — tag, branch, or SHA. We pin to a SHA so each
   aggregate run is reproducible; bump it when we want to follow
   develop forward.

2. gitaggregate.yml — resolve the ref through 'refs/tags/<ref>' first,
   then 'upstream/<ref>', falling back to the raw value. Output and
   variable renamed upstream_tag → upstream_ref to match the new
   semantics.

3. common/scripts/show_tag.js — read common/scripts/version.txt before
   trying 'git describe --tags'. Develop has no tag in its ancestry
   so the previous git-describe-only path printed the hardcoded "0.6.0"
   fallback, which then showed up next to the Help & Support button.
   Matches the pattern already used by show_version.js.

Signed-off-by: Don Kendall <dkendall@ledoweb.com>
dnplkndll added a commit to ledoent/platform that referenced this pull request May 15, 2026
The feat/* branches got rebased onto upstream/develop to clear the
DCO + merge-conflict blockers on PRs hcengineering#10705 and hcengineering#10624. That puts
commits on those branches that depend on files only present in
develop > v0.7.423, so the prior 'merge feat/* onto v0.7.423 tag'
flow now conflicts.

Three changes:

1. ledoent/upstream-tag.txt → develop tip (fa2930d). The file now
   carries any ref — tag, branch, or SHA. We pin to a SHA so each
   aggregate run is reproducible; bump it when we want to follow
   develop forward.

2. gitaggregate.yml — resolve the ref through 'refs/tags/<ref>' first,
   then 'upstream/<ref>', falling back to the raw value. Output and
   variable renamed upstream_tag → upstream_ref to match the new
   semantics.

3. common/scripts/show_tag.js — read common/scripts/version.txt before
   trying 'git describe --tags'. Develop has no tag in its ancestry
   so the previous git-describe-only path printed the hardcoded "0.6.0"
   fallback, which then showed up next to the Help & Support button.
   Matches the pattern already used by show_version.js.

Signed-off-by: Don Kendall <dkendall@ledoweb.com>
dnplkndll added 5 commits May 30, 2026 15:46
Resolve conflicts:
- migrations: keep develop's V26 (pending_configuration); api_tokens table
  (with scopes column) becomes V27
- lang files: union of develop's keys and the API token strings

Signed-off-by: Don Kendall <dkendall@ledoweb.com>
Address @aonnikov's review: the bespoke checkApiTokenRevoked RPC and the
ad-hoc revocation cache in the transactor are replaced by a reusable
verifyToken in server-token that checks signature, expiry, and (for
revokable API tokens) revocation via a pluggable checker.

- server-token: add verifyToken + isTokenExpired + setApiTokenRevocationChecker
  (the 'method to verify' metadata the plugin needs, without depending on the
  account client). Revocation cache (60s TTL) now lives here, reusable by any
  service (transactor, blob access, etc.).
- account: the account is now authoritative — wrap() rejects revoked/expired
  API tokens, so any account method (selectWorkspace/getWorkspaceInfo/...)
  naturally 401s. Removed the redundant checkApiTokenRevoked method.
- account-client: drop checkApiTokenRevoked.
- transactor: withSession uses verifyToken; the registered checker reuses the
  existing getLoginInfoByToken instead of a dedicated boolean RPC. Scopes are
  parsed once and threaded through (no re-decode in the tx handler).
- tests: verifyToken/isTokenExpired unit coverage.

Signed-off-by: Don Kendall <dkendall@ledoweb.com>
…ndpoint

Address @aonnikov: the REST API host must come from the transactor endpoint
returned by the account service (the login endpoint, a ws(s):// URL), not be
constructed from window.location. Convert ws->http and append /api/v1, matching
the existing ServerManagerGeneral pattern.

Signed-off-by: Don Kendall <dkendall@ledoweb.com>
Address @ArtyomSavchenko: the new API token UI strings were left in English
in non-en locales. Provide translations for ru/de/es/fr/it/pt/pt-br/zh/ja/cs/tr.
The en/ru locale-parity test passes (the prior failure was an en/ru key
mismatch, resolved by the develop merge).

Signed-off-by: Don Kendall <dkendall@ledoweb.com>
…DocsSection)

Signed-off-by: Don Kendall <dkendall@ledoweb.com>
@dnplkndll

Copy link
Copy Markdown
Contributor Author

Thanks for the reviews — pushed an update addressing the feedback. Conflicts with develop are resolved (merged current develop; the API token migration is renumbered to V27 after develop's V26 pending_configuration).

@aonnikov

  • Revocation logic / redundant boolean method: reworked. Token expiry + revocation now live in a new verifyToken() next to decodeToken() in server-token, with a pluggable checker (setApiTokenRevocationChecker) so the policy is reusable across services. The account is now authoritative — wrap() rejects revoked/expired API tokens, so selectWorkspace/getWorkspaceInfo/etc. return 401 naturally. The dedicated checkApiTokenRevoked RPC is removed; the transactor's checker reuses the existing getLoginInfoByToken.
  • Transactor endpoint in ApiDocsSection: fixed — the REST base URL is now derived from the account-provided transactor endpoint (ws→http), matching the ServerManagerGeneral pattern, instead of window.location.
  • Manual translations in the create popup: it uses DropdownLabelsIntl with i18n strings (no hand-rolled translations).

@ArtyomSavchenko

  • Role restriction: token creation/revocation now require AccountRole.User or higher via verifyAllowedRole(...) (guests excluded).
  • Locale tests / untranslated strings: en/ru parity restored and the API token strings are now translated across all locales (cs/de/es/fr/it/ja/pt/pt-br/ru/tr/zh).
  • Formatting: re-ran the project formatter; operations.ts and the scope test are clean.

Unit tests added for verifyToken (signature/expiry/revocation) and scope enforcement. Happy to iterate further.

@ArtyomSavchenko

Copy link
Copy Markdown
Member

Hi @dnplkndll
Sorry for the delay in the review.
Could you please resolve the merge conflicts?
If you don't have time for this, let me know, and I can finish it.

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.

3 participants