feat(api): add the public write routes proxying the internal write services - #2084
Open
ka1kqi wants to merge 14 commits into
Open
feat(api): add the public write routes proxying the internal write services#2084ka1kqi wants to merge 14 commits into
ka1kqi wants to merge 14 commits into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
The internal write routes re-declared the numeric ranges that the write services already enforce. The route schema runs first, so zod's generic "Too small: expected number to be >=1" reached public API callers and the service's canonical "traceTtlDays must be an integer between 1 and 365" was unreachable — contradicting the routes' own stated contract that they validate shape only. Drop the range constraints from traceTtlDays and sampleRate, keeping the type checks so a genuinely malformed body is still rejected at the route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
PR overviewThis pull request adds authenticated public create endpoints for workspaces, projects, detectors, dashboards, and widgets by proxying requests to internal write services. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Scanned with Semgrep · TruffleHog · Trident review. View in Trident Fixed/addressed: 1 · PR risk: 0/10 |
Contributor
There was a problem hiding this comment.
All reported issues were addressed
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Fix all with cubic | Re-trigger cubic
Both branches fixed the audit-after-commit ordering independently; keep the parent's version so the services read the same way across the stack, and keep both changes to the internal detector route's schema (the relaxed sampleRate alongside the service's array messages). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
Constructing the response model raises a pydantic ValidationError, which is a ValueError and so escaped the KeyError/TypeError guard: a 200 envelope with a wrong-typed or null field surfaced as an uncaught 500 instead of the controlled 503 the module documents. Catch it in all five write translators. Also mint a sid in the CLI-JWT read fixtures, matching the issuer now that verification requires one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
json.loads accepts the bare NaN/Infinity tokens, but the write proxy's httpx client re-encodes bodies with allow_nan=False, so a non-finite float that survived validation raised an uncaught ValueError and the route returned a bare 500 — breaking the module's no-uncontrolled-500 contract. Reject non-finite floats at the schema layer (a strict-encode validator on the forwarded JSON payload fields, allow_inf_nan=False on widget filter values) so callers get a clean 422, and catch ValueError around the proxy encode as a fail-closed 503 backstop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
spec, display_config, output_schema, and trigger_conditions were bare dict/list fields persisted verbatim into Postgres JSONB with no size bound anywhere in the ladder — the write rate bucket limits request count, not payload size, so one caller could write gigabytes of JSONB per minute inside their request budget. The strict-encode validator now measures the serialized form and rejects anything over 32 KiB with a clear 422; the zod side is unchanged (the service re-validates semantics — this is a transport guard). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
The revoked-session JWT test only exercised create_workspace, so the liveness dependency could be deleted from the other four write routes without a single failure — a revoked CLI session would keep creating projects, detectors, dashboards, and widgets until its JWT expired. Parametrize the revoked-session 401 across those routes (verified by mutation: dropping the dependency from any one of them now fails). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
Coverage was a unit test of key_write's key string plus a config-table comparison — nothing exercised the shared_limit decorator on any write route, so stripping it (or a scope typo) silently unmetered the write API with CI green. Enable the app's real limiter on in-memory storage and pin the route-level 429 on every write route (verified by mutation: dropping any decorator now fails), plus bucket isolation both ways between reads and writes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
closes #2010
What this adds
The first write surface on the public API: creates only, for workspace, project,
detector, dashboard and widget. Reachable by the CLI and public API with a user
credential (session token or short-lived JWT), and by the in-app agent over internal
routes. There are no updates or deletes in v1.
Enforcement lives in one place — a service layer in TypeScript beside Prisma
(
frontend/ui/src/lib/write-services/). Each service owns validation, role checks,tenancy, idempotency and audit. The FastAPI routes are a thin authenticated proxy: they
authenticate the caller, check the session is still live, apply the write rate-limit
bucket, and forward to an internal route. The proxy fails closed — a network error, a bad
status, a malformed body, or an upstream 401 all become 503; only 400/403/404 pass
through with the service's own message.
Writes are audited.
AuditLogrecords actor, operation, resource, tenancy and transport(
public-apioragent), written after the transaction commits so a failed audit cannever roll back the resource it describes.
Behavior changes reviewers should notice
allowed a VIEWER to write; it now requires MEMBER, matching every other resource. The
detectors UI does not yet hide those controls, so a VIEWER will see buttons that now
return 403. UI gating is a follow-up.
validate inside the transaction, after the project-existence and membership checks, so
an out-of-range
sampleRateagainst a project you cannot see returns 404 rather thanleaking that the value was invalid. This matches how every other service-owned field
already behaved.
existing row rather than erroring. There is no unique constraint behind this, so a
concurrency window exists; an idempotent hit also ignores a differing
traceTtlDays.can persist a widget that renders as an error until the vocabulary validation branch
(later in this stack) lands.
Security notes
actorUserIdfrom the request body, authenticated byX-Internal-Secret. Please confirm/api/internal/*is network-restricted in thedeployed topology — that boundary is what makes this safe.
require MEMBER in that project's workspace. Widget creation scopes the dashboard lookup
through the project, so a dashboard id from another project 404s rather than writing.
Known follow-ups (not blockers)
the cookie routes will delegate to these services incrementally, which also makes UI
writes audited.
_PLAN_LIMITS_WRITEhas starter/pro/enterprise tiers that are currently unreachable —the account stamper hardcodes the free plan, so every caller caps at the free limit.
z.array()/z.union()/z.boolean()type checks, which fire before the services' canonical messages for
outputSchema,triggerConditions,detectionSource,enableRcaandenabled. Clearing that properlyneeds
createDetectorto acceptunknownfor the fields it validates itself — arefactor, not an error-message fix. The range/bounds shadowing is fully gone.
verifyInternalSecretcompares with===rather than a constant-time comparison(pre-existing, but this surface is newly sensitive).
🤖 Generated with Claude Code
https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
Summary by cubic
Closes #2010. Adds the first public API write surface: POST-only create routes for workspaces, projects, detectors, dashboards, and widgets, available to user credentials and the in-app agent through internal routes. Updates and deletes remain unsupported in v1; validation, authorization, tenancy, idempotency, and auditing stay in the TypeScript service layer.
Behavior and security
sidtied to a live session, so revocation blocks all five public write routes; session-token credentials keep their existing introspection path.traceTtlDays.spec,display_config,output_schema, andtrigger_conditionsare limited to 32 KiB each.actorUserIdbehindX-Internal-Secret;/api/internal/*must remain network-restricted.Follow-ups
verifyInternalSecretstill uses a non-constant-time comparison, and the new create operations remain disabled as agent tools.Written for commit 4cc8ca7. Summary will update on new commits.
Update: adversarial-review fixes
Three hardening fixes from a multi-agent adversarial review: NaN/Infinity floats anywhere in a JSON payload now 422 cleanly instead of a bare 500 (with a fail-closed 503 backstop at the proxy encode); JSON payload fields (spec, display_config, output_schema, trigger_conditions) are bounded at 32 KiB serialized; and two coverage holes are closed with mutation-verified tests — session-liveness is now asserted end-to-end on all five write routes, and a route-level test proves every write actually consumes the write rate bucket (and reads do not).