[OPIK-8183] [BE] feat: accept CIPX device tokens and record device_id on cipx identities - #8094
Conversation
… on cipx identities Adds a CIPX device-token branch to the authentication filter, beside the API-key and MCP-OAuth branches. An `Authorization` header starting with `opik_cipx_at_` is posted to cost-api's validator, which owns the signing key and the device registry; opik-backend verifies nothing locally, so no crypto and no key distribution land in this repo, and revocation takes effect within the credentials cache TTL. The validated device id reaches the cipx ingestion subscriber the way the workspace name already does: through RequestContext, onto TracesCreated / TraceCostIntelligenceChanged / SpansCreated at publish time, and into the new cipx_trace_identities.device_id column. It is never read from the trace's metadata, which a client controls. Ships disabled: with cipxTokenValidation.enabled=false the filter never inspects the prefix, so self-hosted and local behaviour is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
⏱️ pre-commit per-hook timing
⏭️ 41 skipped (no matching files changed)
|
…n path Caching only the cost-api validation response left the react-service auth-by-username call on every request, so each ingest batch from each enrolled machine still hit EM -- the cost delegated validation exists to avoid. The CIPX path now mirrors authenticateUsingApiKey: it resolves the full credential once (user, workspace, quotas, permissions, device id) and caches that under the token, so a warm request is one cache read with no outbound call. Cold requests still validate against cost-api and then go through AuthService.authorizeOAuth, which is what resolves quotas and permissions. Scoped to the CIPX path; the MCP OAuth branch is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…vice credential An unauthenticated validate endpoint is a token oracle, so the call now carries a shared service credential as `Authorization: Bearer <token>`. Config gains `cipxTokenValidation.serviceToken` (CIPX_TOKEN_VALIDATION_SERVICE_TOKEN), excluded from toString like the other secret-bearing config fields, and the startup check now refuses a deployment that enables the branch without it as well as without an absolute http(s) URL. The chart carries the variable empty; real deployments should supply it from a secret through envFrom. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only cipx_trace_identities stores device_id; cipx_spends and cipx_spend_blocks reach it by semijoin on trace_id, which migration 000100 already documents as their join key. Carrying the field on span creation therefore added a value nothing reads. SpansCreated and SpanService are back to their pre-change state. TracesCreated and TraceCostIntelligenceChanged keep it -- they are what feeds the identity write. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nse, not the react service Routing a device token through AuthService.authorizeOAuth made it depend on the react service's auth-by-username endpoint, which is disabled on the local stack and answers 404 -- and verifyResponse routes anything but 200/400/401/403 to unexpectedRemoteError, so every device-token ingest was a 500. Even where the endpoint is enabled it would 401: cost-api sends `cipx-device-<uuid>` as the user name, which is not a Comet user. The validate response already carries user name, workspace id, workspace name and device id, so the request context is now filled from it directly. The caching stays, and gets cheaper: the warm path was already one cache read, and the cold path is now one call instead of two. Quotas and permissions are consequently empty, which is why the request context is filled in one place with the exemption stated there: a device token names a machine in an enterprise AI-Spend workspace, not a user, so @UsageLimited cannot trip and @RequiredPermissions goes unverified for it. An ingest-only allowlist is what bounds the credential instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolving the caller from the validation response means no path or permission check happens anywhere else, so the allowlist is the whole authorization a device token gets. These are the tests for it: accepted on the four endpoints the cipx shipper calls, rejected with 403 on reads, on a delete, and on the right path with the wrong method -- the last one proving the method and path have to match as a pair, since POST is allowed elsewhere. Rejections assert no lookup happened either, so a non-ingest path cannot learn whether the token is valid. The accepted cases are served from cache so the allowlist is what is under test rather than the validator call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… prefix The validator was under /v1/private/ai-spend/, which is cost-api's public routing contract -- nginx forwards exactly that prefix to it, so the endpoint would have been reachable from the internet. An endpoint that answers a presented token with a user name, workspace id and device id is a validation oracle, and the service credential would have been the only thing in front of it. It now sits at /v1/internal/cipx-device-tokens/validate, which nothing routes publicly, and the configured URL defaults to the in-cluster service name http://ai-cost-backend rather than the public front door -- the same pattern authentication.reactService.url already follows. The endpoint's only caller is this one, in-cluster. Not a hole being closed: cost-api's device router is not on main, so nothing is exposed today. This lands before exposure exists. The service credential is unchanged -- routing stops the secret being the only control, it does not replace it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The credential was justified by public exposure, and moving the validator
under /v1/internal/ removed that: nothing forwards the endpoint from
outside, so the secret was only ever guarding a cluster-internal path.
Against that it was a long-lived credential with no rotation story and a
two-sided config coupling that fails closed on a mismatch. Routing is the
control now, as it already is for /v1/internal/usage.
Config is back to `cipxTokenValidation: { enabled, url }`. The bearer
header, the field, and the startup check go with it.
One consequence, accepted for the smaller config surface: without the
startup check a relative or blank `url` boots and fails on the first ingest
instead. The branch ships disabled with a working in-cluster default, so
that needs a deployment to have overridden the value deliberately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Backend Tests - Integration Group 14 44 files 44 suites 3m 28s ⏱️ For more details on these errors, see this check. Results for commit 240a215. ♻️ This comment has been updated with latest results. |
ValidatedCipxToken claimed every field but deviceId mirrors what any other credential resolves to. userName does not: for an API key or a session it is a Comet username, and here it is the device's MDM-provisioned email address, which lands in traces.created_by. That made the one field that differs the one described as identical, and the validator's own code is not in this repo to check against. Corrected there, plus a line where it enters the shared request context -- every other writer of that field puts a username in it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Workspace ids, device ids, trace ids and token secrets were literals, which invites coupling to their text and reads as if the values mattered. They are now generated per class -- UUIDs where the real value is uuid-shaped, inline RandomStringUtils elsewhere, following the getRandomId pattern already in AuthCredentialsCacheServiceTest. The cache test's device id is generated once into a local and used as both the argument and the expectation, so the pair cannot drift. Left literal on purpose, because randomising them would stop the tests testing anything: the opik_cipx_at_ prefix, which is what selects the auth branch; every allowlist path; and the __ai_spend_ workspace fence, which is the contract shape of a device's bound workspace -- only its org portion is data. The trace id has to stay uuid-shaped for the same reason, since the allowlist's trace-update pattern matches only a UUID segment. Also gives the project-delete rejection its own id rather than reusing the workspace id in a project path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…or really returns Both fixtures built the user name as cipx-device-<uuid>, which is the fallback the validator uses only when the registry address is blank. The normal case is the device's MDM-provisioned email, so the fixtures now hold a generated one in the style the cipx ingestion test already uses. Nothing asserts on the value today; the point is that the next reader, and any future assertion, sees the right shape. No test for the fallback: cost-api owns it and covers it, and asserting it through a mock here would only test the mock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Worth a test, but not testable yet. The device-token path is behind Testable once (flag): CIPX_TOKEN_VALIDATION_ENABLED=true on an env that also runs a cost-api validator at CIPX_TOKEN_VALIDATION_URL able to mint and validate a device token — the e2e estate has no such service and no way to stub one today What a test would assert: The credential type is the axis, not the endpoint. With the flag on, Recorded rather than dropped, so this resurfaces when the gate opens — a flag is usually flipped by a PR that touches no product code and so is never triaged on its own. Not testable yet. device_id on cipx_trace_identities cannot be asserted from the e2e estate even once the flag is on: nothing in Opik reads the table back — no resource, no endpoint, no frontend reference to cipx at all — so the column is write-only here and only cost-api consumes it. Worth flagging that the per-row positional binds went 25 -> 26 and the listener swallows its own failures, so a slipped index would write every identity row shifted and log rather than surface; CostIntelligenceIngestionTest is the only thing standing under that, which looks like the right place for it. also touches Backend (Java API / internal), Deployment / Helm Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review. Re-checked after a push on 03 Sep 08:28 UTC — nothing the verdict depends on changed. |
BorisTkachenko
left a comment
There was a problem hiding this comment.
Looks good, one question about for scope of this change.
| /** | ||
| * The only endpoints a device token may reach. Rejection is by this list, never by the absence of a binding. | ||
| * Shape follows {@code RemoteAuthService.PUBLIC_ENDPOINTS}: path regex to allowed methods. | ||
| */ | ||
| private static final List<IngestEndpoint> INGEST_ENDPOINTS = List.of( | ||
| new IngestEndpoint(Pattern.compile("^/v1/private/spans/batch/?$"), Set.of("POST", "PATCH")), | ||
| new IngestEndpoint(Pattern.compile("^/v1/private/traces/?$"), Set.of("POST")), | ||
| new IngestEndpoint(Pattern.compile("^/v1/private/traces/" + UUID_REGEX + "/?$"), Set.of("PATCH"))); |
There was a problem hiding this comment.
I might be not fully in the context. But as far as I recall, we were planning to use this token not only for ingestion but also by the macOS application for retrieval. So the user will only see the data which was ingested from his device.
There was a problem hiding this comment.
That is fine. At present retrieval goes via ai-cost-backend application, not via opik. It will be on ai-cost-backend side to validate device token and control data fetch
Details
Adds a CIPX device-token authentication branch to opik-backend, beside the existing API-key and MCP-OAuth branches, and records the validated machine identity on the Cost Intelligence identity table. An
Authorizationheader starting withopik_cipx_at_is posted to cost-api'sPOST /v1/private/ai-spend/devices/validate; cost-api owns the signing key and the device registry, so opik-backend verifies nothing locally (no crypto, no key distribution in this repo) and revocation takes effect within the credentials-cache TTL.Auth —
CipxTokenUtils.isCipxToken->CipxTokenValidationService.authenticate, which validates against cost-api and fills the request context straight from the validation response. The react service is not consulted on this path:/validatealready returnsuser_name,workspace_id,workspace_nameanddevice_id, and a device token names a machine rather than a Comet user, so there is nobody for EM to authenticate. (An earlier revision of this branch routed it throughAuthService.authorizeOAuth; on a stack whereauth-by-usernameis disabled that is a 404, whichverifyResponseturns into a 500 on every ingest.)The ingest path is one cached lookup. The resolved caller — user, workspace, device id — is cached under the token in the existing
AuthCredentialsCacheServiceat its existingapiKeyResolutionCacheTTLInSec(5s), mirroringauthenticateUsingApiKey. A warm request is a single cache read with no outbound call at all; a cold request is one validate call. No permissions are passed to the cache, because nothing verified any and nothing may be cached as granted. The cache instance is now provided once inAuthModuleand shared by both callers. The MCP-OAuth path is deliberately untouched.No
Comet-Workspaceheader on this path — validated and cached by token alone. A token maps 1:1 to a device and a device to the workspace it enrolled against, so the workspace is derived, never supplied:AuthFilterdoes not pass the header into the branch (so the branch cannot read it),workspace_nameis not in the validate request body, andRequestContext.workspaceNamecomes from the validate response with no fallback. The header was only ever a null-fallback the response does not need, and a blank one was accepted anyway, so it blocked no attacker holding a token — only an honest client. Token-only keying is safe and slightly better: nothing invalidates this cache by workspace (entries expire purely by TTL, andCacheServiceexposes onlycache/resolve), API-key entries always carry a non-blank workspace becauseRemoteAuthService.authenticate403s on a blank one — soauthV2-<token>-is a namespace only CIPX occupies — and there is now one entry per device instead of one per header variant, which removes a cache-busting vector. The API-key and MCP-OAuth branches still require the header and must keep doing so; they reach many workspaces.Ingest-only allowlist — this is the whole authorization the credential gets. Because the caller is resolved locally, no path or permission check happens anywhere else, so
CipxTokenValidationService.INGEST_ENDPOINTSrestricts a device token to exactly what the cipx shipper calls; everything else is 403, checked before the token is validated so a non-ingest path cannot learn whether it is valid. Shape follows the in-repoRemoteAuthService.PUBLIC_ENDPOINTSprecedent.POST/v1/private/spans/batchPATCH/v1/private/spans/batchPOST/v1/private/tracesPATCH/v1/private/traces/{uuid}Quotas and permissions are empty for a device token, by decision. There is no Comet user to resolve a role or a quota for, so
@UsageLimited(TracesResource,SpansResource) cannot trip and@RequiredPermissions(TRACE_SPAN_THREAD_LOG)goes unverified for this credential. That is accepted for an enterprise AI-Spend workspace, and the allowlist above is what bounds it instead. The exemption is stated on the method that fills the request context so it reads as a decision rather than an oversight — reinstating the EM call is explicitly warned against there, because it does not work.The validator is reached in-cluster, off any publicly routed prefix — and that routing is the control. The call is
POST /v1/internal/cipx-device-tokens/validateonhttp://ai-cost-backend, the k8s service name, the same patternauthentication.reactService.urlalready follows withhttp://react-svc:8080. This is a security property, not a routing preference: cost-api's public contract is the/v1/private/ai-spend/prefix, which nginx forwards to it from the internet, so a validator there would have been an internet-reachable oracle turning a presented token into a user name, workspace id and device id. Under/v1/internal/nothing routes it publicly. Nothing is exposed today either way — cost-api's device router is not yet onmain— so this lands before exposure exists. Do not move it back under the ai-spend prefix; the reason is recorded on the class.The call itself is unauthenticated, as opik's own
/v1/internal/usagealready is. An earlier revision of this branch carried a sharedserviceTokenbearer credential; it was justified by public exposure, which the routing above removes, and it cost a long-lived secret with no rotation story plus a two-sided config coupling that fails closed on a mismatch. Config is therefore just{ enabled, url }.device_idpropagation — carried onRequestContext, put into the reactive context byAsyncUtils, and attached toTracesCreatedandTraceCostIntelligenceChangedat publish time inTraceService, following the existingTracesCreated.workspaceNameprecedent (the cipx subscriber runs off the request path and cannot read the request context).Write path —
CostIntelligenceIngestionListenerandCipxTraceIdentityDAOwritedevice_idfrom the event, never fromtrace.metadata(), so a client cannot claim another machine's identity by writing one into its own metadata. Empty string when absent, matching howharnesshandles legacy rows.Migration —
000119_add_device_id_to_cipx_trace_identities.sqlondb-app-analytics:ADD COLUMN IF NOT EXISTS device_id String DEFAULT ''(plainString, notLowCardinality: one value per enrolled machine), with a--rollbackline.Config / chart —
cipxTokenValidation: { enabled, url }inconfig.ymlplusCipxTokenValidationConfig, wired likeMcpOAuthConfig;CIPX_TOKEN_VALIDATION_ENABLEDandCIPX_TOKEN_VALIDATION_URLin the Helm chart.urldefaults tohttp://ai-cost-backend, inert whileenabledis false. There is no startup validation ofurl: a relative or blank override boots and fails on the first ingest instead. That is the accepted trade for a smaller config surface — the branch ships disabled with a working in-cluster default, so reaching that state takes a deployment deliberately overriding the value.Ships disabled by default. The filter checks
cipxTokenValidation.enabledbefore it looks at the token prefix, so while disabled it never inspects the header and existing behaviour is unchanged for self-hosted, OSS and local dev.Change checklist
Issues
AI-WATERMARK
AI-WATERMARK: yes
Testing
mvn compile,mvn test-compile,mvn spotless:check— all clean (scripts/dev-runner.sh --lint-bealso runs clean via the pre-commit hook).mvn test -Dtest=CipxTokenValidationServiceTest,AuthFilterCipxTokenTest— 13/13 pass. Four accepted cases (one per allowlisted method+path pair) and seven rejections: reading traces, reading spans, reading one trace by id,POST /v1/private/traces/search,GETon the allowlisted span-batch path,DELETEon a project, andPOST /v1/private/traces/delete. The search and wrong-method cases are there becausePOSTis allowed on other paths — the pair has to bite, not the method alone. Rejections also assert that no cache or validator interaction happened.AuthFilterCipxTokenTestdrives the real service through the filter to pin what is invisible from inside it: a device token authenticates with noComet-Workspaceheader (verify(context, never()).getHeaderString(WORKSPACE_HEADER)), and two requests carrying different workspace headers produce the identical cache lookup — the property token-only keying buys.mvn test -Dtest=AuthCredentialsCacheServiceTest— 16/16 pass, including the addedcacheAndRetrieveDeviceId(device id survives the round-trip; a non-device credential reads back as no device rather than a blank one).CostIntelligenceIngestionTestgains adevice_idassertion (an API-key caller's identity row carries'', and the column is not read from metadata). Not executed here — it is testcontainers-backed with ClickHouse and MySQL.serviceTokenand the@AssertTruegone there is no such rule left to assert.Documentation
Chart README rows regenerated for the three new env vars. No user-facing docs: the feature is off by default and SaaS-only.
Deliberately out of scope
SpansCreatedis deliberately untouched. Onlycipx_trace_identitiesstoresdevice_id;cipx_spendsandcipx_spend_blocksreach it by semijoin ontrace_id, their documented join key since migration000100. An earlier revision carried the field on span creation; it has been reverted and the file is byte-identical tomain.🤖 Generated with Claude Code