Skip to content

feat(api): add the public write routes proxying the internal write services - #2084

Open
ka1kqi wants to merge 14 commits into
feat/write-session-guardfrom
feat/write-services
Open

feat(api): add the public write routes proxying the internal write services#2084
ka1kqi wants to merge 14 commits into
feat/write-session-guardfrom
feat/write-services

Conversation

@ka1kqi

@ka1kqi ka1kqi commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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. AuditLog records actor, operation, resource, tenancy and transport
(public-api or agent), written after the transaction commits so a failed audit can
never roll back the resource it describes.

Behavior changes reviewers should notice

  • VIEWERs lose detector create/update/delete. The cookie detector route previously
    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.
  • Validation errors for an invisible project now 404 instead of 400. The services
    validate inside the transaction, after the project-existence and membership checks, so
    an out-of-range sampleRate against a project you cannot see returns 404 rather than
    leaking that the value was invalid. This matches how every other service-owned field
    already behaved.
  • Creates are idempotent on a natural key (owner+name, project+name) and return the
    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.
  • Widget specs are accepted here with field names validated only at query time. A caller
    can persist a widget that renders as an error until the vocabulary validation branch
    (later in this stack) lands.

Security notes

  • Internal write routes trust actorUserId from the request body, authenticated by
    X-Internal-Secret. Please confirm /api/internal/* is network-restricted in the
    deployed topology
    — that boundary is what makes this safe.
  • Project-scoped writes resolve the project first, 404 on missing or soft-deleted, then
    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.
  • API keys are rejected at account scope; project ingest keys can never write.

Known follow-ups (not blockers)

  • The services hand-mirror validation that the cookie routes still implement separately;
    the cookie routes will delegate to these services incrementally, which also makes UI
    writes audited.
  • _PLAN_LIMITS_WRITE has starter/pro/enterprise tiers that are currently unreachable —
    the account stamper hardcodes the free plan, so every caller caps at the free limit.
  • The detector internal route still declares its own z.array() / z.union() / z.boolean()
    type checks, which fire before the services' canonical messages for outputSchema,
    triggerConditions, detectionSource, enableRca and enabled. Clearing that properly
    needs createDetector to accept unknown for the fields it validates itself — a
    refactor, not an error-message fix. The range/bounds shadowing is fully gone.
  • verifyInternalSecret compares 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

  • JWT writes require a sid tied to a live session, so revocation blocks all five public write routes; session-token credentials keep their existing introspection path.
  • All five routes use the write rate-limit bucket, while account API keys and project ingest keys cannot write.
  • Project-scoped writes return 404 for missing, deleted, or inaccessible projects and require MEMBER access.
  • Creates match natural keys and return existing rows; the lack of a backing unique constraint leaves a concurrency window, and a match ignores a different traceTtlDays.
  • Cookie detector writes now require MEMBER, so VIEWERs still see controls but receive 403 until UI gating lands.
  • Invisible-project validation now returns 404 instead of 400.
  • Internal routes defer range and deep validation to the services so their canonical error messages reach callers.
  • Widget specs defer field validation until query time, so invalid specs can persist and render errors.
  • Non-finite numbers are rejected and spec, display_config, output_schema, and trigger_conditions are limited to 32 KiB each.
  • Audits run after commit, and malformed upstream responses or other proxy failures fail closed as 503.
  • Internal routes trust actorUserId behind X-Internal-Secret; /api/internal/* must remain network-restricted.

Follow-ups

  • Plan-tier write limits remain unreachable because account stamping hardcodes the free plan.
  • verifyInternalSecret still 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.

Review in cubic


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).

ka1kqi and others added 5 commits August 27, 2026 07:32
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
Comment thread backend/rest/routers/public/deps.py Outdated
@trident-sentinel

trident-sentinel Bot commented Sep 1, 2026

Copy link
Copy Markdown

PR overview

This 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 review

No open security issues remain on this pull request.

Scanned with Semgrep · TruffleHog · Trident review. View in Trident

Fixed/addressed: 1 · PR risk: 0/10

@ka1kqi ka1kqi changed the title feat(api): add the public write paths for workspaces, projects, detectors and dashboards feat(api): add the public write routes proxying the internal write services Sep 1, 2026
@ka1kqi
ka1kqi changed the base branch from main to feat/write-session-guard September 1, 2026 17:20

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread backend/rest/routers/public/project_write.py Outdated
Comment thread backend/rest/routers/public/account_write.py Outdated
ka1kqi and others added 8 commits September 1, 2026 13:22
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
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.

feat(api): write service layer, internal write routes, and public create endpoints

1 participant