Repository navigation
Description
Part of Eyevinn/osaas-token-service#115.
Blocked by: Eyevinn/osaas-token-service#118, Eyevinn/osaas-token-service#119
Source of truth
ADR-0068 (https://github.com/Eyevinn/agent-team-osc-dev/blob/main/adr/0068-fine-grained-access-tokens.md), epic Eyevinn/osaas-token-service#115, birme decision https://github.com/Eyevinn/osaas-token-service/issues/115#issuecomment-6614344808 (D1 split approved, D2 option A, D3, release mechanism). The architect sequenced this work and cut nothing; if a premise below is false, post ## Spec Invalid (see Verify first). The API this ticket calls is defined by T03 (Eyevinn/osaas-token-service#118, /tokens*) and T15 (Eyevinn/osaas-token-service#119, GET /audit/events); their merged code is the contract, not this text.
ADR-0068 text this ticket implements (verbatim; the ADR is the source of truth)
6. Management API (table)
| Endpoint | Purpose |
|---|---|
POST /tokens |
Create from a template or policy. The token value is returned once. |
GET /tokens, GET /tokens/{id} |
Paginated list (tenant-scoped) and detail. |
PUT /tokens/{id}/policy |
Replace the policy. Takes expectedVersion (optimistic concurrency) and bumps policyVersion. This is the "change scope, same token" call. |
PATCH /tokens/{id} |
Change name, description or status. Not exp. |
DELETE /tokens/{id} |
Delete the record and set Redis status to revoked. |
POST /tokens/{id}/simulate |
Dry-run: would action X on resource Y be allowed? |
8. Export (excerpt)
Export. Available in both UIs for the current filter. Formats are NDJSON (full fidelity) and CSV (flat, with the diff as a JSON string). Timestamps are ISO-8601 UTC.
- It is streamed through an osaas-app route handler that pages the token-service cursor API, so bytes flow continuously and the 60 s ingress idle timeout does not apply. A single export is capped at 500,000 events or a 31-day range, whichever is first, and larger ranges are fetched in chunks.
- Each export is itself recorded as
audit.exportedwith the actor, filter and row count. - The CLI and MCP get read access to the same data (
osc token audit, a read-only MCP tool), with the same tenant scoping.
9. Surfaces
osaas-app (decision 8), an osc token CLI command in client-ts, MCP tools in osaas-ai, and a Terraform resource osc_access_token in terraform-provider-osc. All call the same API.
Delivers
SDK functions in @osaas/client-core for every /tokens* route of T03 and for GET /audit/events of T15, and an osc token command group in @osaas/cli that uses them, including osc token audit for reading and exporting audit events as NDJSON or CSV. No other package changes behaviour.
Readiness precondition (checked by the Triage pass, not by the implementer)
This ticket is created in Triage and moves to Backlog only when both probes hold on dev:
curl -s -o /dev/null -w "%{http_code}" -X POST https://token.svc.dev.osaas.io/tokens
curl -s -o /dev/null -w "%{http_code}" https://token.svc.dev.osaas.io/audit/events
Met: the first prints 401 (404 means TOKENS_API_ENABLED is off on dev, 503 means the token store is not configured) and the second prints 401 (503 means the audit store is not configured, 404 means T15 is not deployed). Both checks are unauthenticated and change nothing. A Triage pass that finds a probe unmet posts a NEW ## Architect Triage comment classified Checkpoint (not ready), with one Unverified: line per failed probe that cites the Phase 0 dev-values row of Eyevinn/osaas-token-service#115; the second consecutive such comment labels this ticket Human Needed through the checkpoint rule, and birme removes the label after setting the dev values. A pass never refreshes an earlier checkpoint in place.
Verify first (before any code)
Re-run every grep and citation below against current main (read on 2026-11-05 at client-ts ce30cb2; counts and line numbers drift). If a premise is false (a file, route, field, function or credential does not exist), post ## Spec Invalid on this ticket stating what is actually true, and stop; do not route around it.
- Read the merged T03 and T15 code in Eyevinn/osaas-token-service (
src/route schemas and their tests) and take from it, not from this ticket: the request body ofPOST /tokens(name, description, template or policy, lifetime field), the response field that carries the token value, the list pagination parameters and response fields ofGET /tokens, the body ofPUT /tokens/{id}/policy(policy,expectedVersion) andPATCH /tokens/{id}, the body and response ofPOST /tokens/{id}/simulate, the query parameter and response field names of theGET /audit/eventscursor, and theexport=truequery. Quote the names you used in the PR body with the token-service commit SHA. packages/core/src/context.ts:getPlatformEnvironment()exists andgetServiceAccessTokencallshttps://token.svc.${platformEnv}.osaas.io/servicetokenwith'x-pat-jwt': Bearer <PAT>(lines 91-118 on ce30cb2). The new functions use the same host derivation (prod-semaps toprod) and the same header.packages/core/src/fetch.ts:createFetch<T>(url, options, errorFactory?)andFetchErrorwithhttpCode; the default error factory usesmessage ?? reasonand drops anycodefield.packages/cli/src/cli.ts: there is notokencommand today (it registerscmdMyapp(),cmdUser.cmdServiceAccessToken()asservice-access-token, andcmdAdmin()which hasgen-pat), global options--env <environment>and--json, and thepreActionhookensureToken(environment). Re-check thatosc tokenstill collides with nothing.
Directions
- SDK (
packages/core/src/accesstoken.ts, exported frompackages/core/src/index.ts):listAccessTokens(ctx, opts?)(one page, with the T03 pagination parameters),getAccessToken(ctx, id),createAccessToken(ctx, body),replaceAccessTokenPolicy(ctx, id, policy, expectedVersion),updateAccessToken(ctx, id, patch)(name, description, status, enforcement, auditLevel as T03 accepts them; the TypeScript type has noexpfield),deleteAccessToken(ctx, id)(noContent-Typeheader on DELETE, asclearSuspensionRecorddoes),simulateAccessToken(ctx, id, action, resource),listAuditEvents(ctx, filter, cursor?)(one page) anditerateAuditEvents(ctx, filter, { export })(an async generator over pages that sendsexport=trueon every page whenexportis set and carries the opaque cursor unchanged). Base URLhttps://token.svc.${ctx.getPlatformEnvironment()}.osaas.io, header'x-pat-jwt': 'Bearer ' + ctx.getPersonalAccessToken(). Types (AccessToken,TokenPolicy,PolicyStatement,AuditEvent,AuditFilter) are written in this file from the T03 and T15 schemas. Use a module-local error factory that keeps the server'scodein the error message (for exampleinsufficient_scope) without changingFetchErroror the default factory for existing callers. - No
@osaas/tokendependency (dependency policy, four points): (1) the CLI needs only request and response types, which own code covers; (2) it would use none of the library's runtime (verifier, Redis pipeline, compiler); (3)npm view @osaas/token@0.13.0 dependencieslistsioredisandfast-jwt, which a CLI does not need; (4) the sensitive part (policy evaluation) stays server-side in token-service. The server validates every policy; the CLI does no local policy validation beyond parsing JSON. - CLI (
packages/cli/src/token/cmd.ts,cmdToken()added incli.tswithcli.addCommand), followingmyapp/cmd.ts: environment fromcommand.optsWithGlobals().env,--jsonwrites structured JSON to stdout and human text to stderr, errors as{ "error": ... }on stderr in JSON mode, andprocess.exitCode = 1on any failure (asadmin/cmd.tsdoes).osc token list: follows every page; prints id, name, status, expiry,policyVersion, last used. When the response carriesscope: "own"printShowing your own tokens onlyto stderr, and when it carriesroleLookup: "unavailable"print a warning that the workspace role could not be resolved and the list may be incomplete (T03 degraded signal).osc token describe <id>.osc token create --name <name> [--description <text>] (--template <name> | --policy <file|->) [--expires-in-days <n>]: exactly one of--templateand--policy(else exit 1 before any request); help lists the six templates of ADR decision 2 (viewer,deployer,ci-publisher,agent-runner,mcp-readonly,full) and the server decides validity. The token value is printed once: in text mode the value alone on stdout and the metadata plus "this value is shown once and cannot be retrieved again" on stderr, soTOKEN=$(osc token create ...)captures only the value; in--jsonmode the whole create response on stdout. The value is never written to disk, never passed tosaveToken, and never logged (noLog().debugof a body).osc token set-policy <id> --policy <file|-> --expected-version <n>: a 409 prints that the policy changed since version n and exits 1 without retrying.osc token update <id> [--name] [--description] [--enforcement enforce|audit] [--audit-level default|full], plusosc token disable <id>andosc token enable <id>(PATCHstatus). There is no option to change the expiry: ADR decision 1 fixesexp; the help ofupdatesays to create a new token instead.osc token delete <id> [--yes]: asks for confirmation with the existingconfirmhelper fromuser/utilunless--yes.osc token simulate <id> --action <action> --resource <resource>: prints allowed or denied and the reason.osc token audit [--token <patId>] [--user <userId>] [--kind <kind>] [--action <action>] [--decision allow|deny] [--from <iso>] [--to <iso>] [--limit <n>]: a read (noexport=true), newest first, default limit 100, table or--json. With--export ndjson|csv [--output <file>]it is an export: every page is requested withexport=trueand written as it arrives (never buffered whole), to stdout or--output. NDJSON is one event per line, unchanged. CSV has one header row with the event fields of ADR decision 7 in ADR order, RFC 4180 quoting,detailsas a JSON string, timestamps ISO-8601 UTC; the CSV writer is own code.--fromand--toare required for an export; a range over 31 days is split into consecutive windows of at most 31 days, each its own export, and a window that reaches 500,000 rows stops with a stderr line naming the lasttswritten so the user can continue from it (ADR decision 8 cap). The CLI posts noaudit.exportedevent: token-service records one per export page (T15). The samescopeandroleLookupnotices aslistgo to stderr; a 403insufficient_role(filter on another user as a non-manager) orinsufficient_scopeprints the reason and exits 1.
- Status handling (new commands only):
POST /tokensanswering 404 printsCreating access tokens is not enabled in <env> yet(T03 keeps the route behindTOKENS_API_ENABLED, off in prod until T13); a 503 prints the server reason (token store not configured,audit store not configured); a 404 on an{id}route prints the server reason (token not foundorrole lookup unavailable). - Do not change any
package.jsonversion, runlerna version,npm run lerna:versionornpm run publish, or write a commit message containingchore(release):.github/workflows/main.ymlpublishes to npm on a push whose message containschore(release): publish, and releasing client-ts is birme's act.
Acceptance criteria
-
packages/coreexports the SDK functions and types above;npm run typecheck,npm run lint,npm run prettyandnpm run testpass at the repo root (the checks of.github/workflows/pull-request.yml). - Core unit tests (jest,
createFetchmocked as insuspension.test.ts): each function calls the expected method and URL ontoken.svc.dev.osaas.ioforenvironment: 'dev'and ontoken.svc.prod.osaas.ioforenvironment: 'prod-se', withx-pat-jwt: Bearer <pat>; DELETE sends noContent-Type;iterateAuditEventswithexportsendsexport=trueon every page and passes each returned cursor back unchanged, and withoutexportsends noexportparameter; the error factory turns403 {reason, code: 'insufficient_scope'}into aFetchErrorwithhttpCode403 whose message contains both. - CLI tests (mocking
@osaas/client-corecreateFetch, asadmin/cmd.syncfork.test.tsdoes), rejection side:createwith both--templateand--policy, or neither, exits 1 with no request;set-policyon 409 exits 1 and does not retry;audit --user otheron 403insufficient_roleexits 1 and prints the reason;createon a 404 fromPOST /tokensprints the not-enabled message and exits 1;audit --exportwithout--fromand--toexits 1 with no request. - CLI tests, passing path:
create --template viewerprints only the token value on stdout in text mode and the full response in--jsonmode, and the value appears in no other output stream and in no file;listacross two pages prints both pages and thescope: "own"notice;delete --yessends DELETE without prompting;audit --export ndjsonover two pages writes exactly the rows returned, one JSON object per line, in order;audit --export csvwrites one header row and quotes a field containing a comma, a quote and a newline correctly, withdetailsas a JSON string; an export over a 40-day range makes two windows (31 and 9 days). - No new runtime dependency in any
package.json;package-lock.jsonchanges only for the workspace links if at all. - Agent-run dev check (needs
test_pat=dev; tenanttest_tenant), run from the PR branch afternpm run build, withOSC_ACCESS_TOKEN=$TEST_PAT node packages/cli/dist/cli.js --env dev ...: (1)token create --name t09-check --template viewer --expires-in-days 1returns a value; (2)token describe <id>showspolicyVersion1; (3)token set-policy <id> --policy <file> --expected-version 1succeeds anddescribeshows version 2 with the same id; (4)token disable <id>thendescribeshowsdisabled; (5)token audit --export ndjson --from <now-10m> --to <now>writes N rows; (6)token audit --kind audit.exported --from <now-10m> --to <now> --json(a read, not an export) showsaudit.exportedevents whosedetails.exportIdvalues are equal within that export and whosecountvalues sum to N; (7)token delete <id> --yes. The PR body quotes the commands and outputs with the token value replaced by<redacted>. A 401 or 403 from dev on these calls is the missing capability to name, not a reason to change code. - Closing comment (
What a human does next): to make the commands public, birme releases client-ts with its lerna flow (npm run lerna:versiononmain, which makes thechore(release): publishcommit thatmain.ymlpublishes from); until T13 has switchedTOKENS_API_ENABLEDon in prod,osc token createagainst prod prints the not-enabled message. How to test after a release:OSC_ACCESS_TOKEN=<dev PAT> osc --env dev token create --name check --template viewer, expected a token value on stdout;osc --env dev token audit --export csv --from <iso> --to <iso>, expected a CSV header and rows.
Executor and authorisation (EyevinnOSC pipeline)
Facts read on 2026-11-05 (gh with birme's account, read-only):
- The
EyevinnOSCorganisation has one GitHub App installed,slack.eyevinn-pr-reviewerandeyevinn-developerare installed on everyEyevinnrepository but not inEyevinnOSC(gh api orgs/EyevinnOSC/installations). EyevinnOSC/client-tsis in neitherPROJECT_REPOS(.claude/scripts/focus-work-preflight.sh) norREVIEWER_APP_PILOT_REPOS(.claude/scripts/reviewer-app-repos.sh); it is in.claude/scripts/scan-issues.sh. pr-reviewer therefore uses comment-based review here, andmerge-if-ready.shmerges throughas-developer.sh(theeyevinn-developerApp) with no fallback, which cannot succeed in an organisation where that App is not installed.mainhas no branch protection and no ruleset. The last six merged PRs (feat(cli): sync-fork --repository-url for the dedicated sync-fork key #169, fix(core): keep the original error as cause on FetchError #168, feat(core): clearSuspensionRecord helper, cleared after CLI creates #166, fix(admin): dedupe instances, treat 409 already-suspended as idempotent skip #165, fix(admin): fix error handling and exit codes in admin CLI commands #161, fix: don't abort suspending a tenant's remaining instances when one is already suspended #158) were all authored and merged bybirme, with no review on any of them.- Repository permissions
adminandpushare true for birme's account. For the runner identity, the daily-triage run of 2026-11-05T03:55Z reportedpermissions.pushtrue andcreateIssuerefused (Resource not accessible by personal access token); a repository role does not grant the fine-grained token's Issues or Pull requests scopes.
Implementer: clone EyevinnOSC/client-ts into the scratchpad, git fetch origin, branch from origin/main, and push from there; never run git remote set-url|add|rename|remove in the team repository. Fallback when a pipeline step cannot run here (the push, gh pr create, the review, or the App merge is refused): the implementer posts a comment on this ticket starting with ## Pipeline blocked that names the step and quotes the error, and stops without trying another identity or route; the orchestrator labels the ticket Human Needed with the question for birme: merge by hand, or install the eyevinn-developer and eyevinn-pr-reviewer Apps in EyevinnOSC. A hand merge closes this ticket through the PR's closing keyword and unblocks T19 and the parent. If the comment on this ticket is itself refused (the same Issues scope gap), post it on Eyevinn/osaas-token-service#115 with this ticket's full reference instead.
PR closing keywords
The PR body carries Closes EyevinnOSC/client-ts#<this ticket's own number> on its own line and no other closing keyword.
PR body requirements
## Lessons section; the Co-Authored-By: Claude <noreply@anthropic.com> trailer and the Claude Code footer; a What a human does next statement; the four dependency-policy points above (no new dependency); the T03 and T15 field names used, with the token-service SHA. Every new rejection branch has a passing-path test in the same PR. No ticket is created for a person to configure, verify or release anything.
Contract
Everything is new: new SDK exports, a new CLI command group, calls to new token-service routes. No existing command, export or endpoint changes behaviour.
Unverified: the exact request and response field names of /tokens* and GET /audit/events (T03 and T15 are not merged; Verify first item 1); whether the runner token can create a PR, comment or apply labels in EyevinnOSC/client-ts (only push is reported, and createIssue was refused; until birme grants Issues and Pull requests write on this repository, every agent write to this ticket, including the Triage checkpoint comments above, may fail); whether the runner holds admin here (read only for birme's account); whether TEST_PAT is accepted by GET /audit/events on dev with the role rule giving it the tenant (the dev check reports a 401 or 403 as the missing capability); whether osaas-e2e-tests covers any osc CLI command (not grepped; the command names are new).
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5.5 noreply@anthropic.com
Activity