fix(server): watcher device-pin authz + table-schema drift test runs in CI#1062
Conversation
A watcher pinned to a device worker runs its agent CLI on the device owner's machine. manage_watchers create/update stored device_worker_id without verifying the caller may target that device, so a member-write actor could pin a watcher to another user's device (privilege escalation). Add a pure decision helper (evaluateDeviceWorkerAccess) plus a DB-backed assertion (assertDeviceWorkerAccess) mirroring watcher-execution-config: the caller must own the device, or be org owner/admin over a device attached to their org; system/internal callers are exempt. Wired into handleCreate and handleUpdate; rejects with ToolUserError (403). Unit-tests the role x ownership matrix (owner/admin/member/system x owned/foreign/missing) and adds an end-to-end gate assertion in watchers-crud (foreign-org pin rejected on create + update).
…efinitions.api_type The drift describe was gated on `describe.skipIf(!process.env.DATABASE_URL)` evaluated at module load. In the embedded-PG path global-setup sets DATABASE_URL in the setup process, but whether a forked vitest worker observes it at module-load time is timing-dependent, so the block could silently self-skip and the gate was toothless. Gate instead on the resolved URL global-setup now `provide()`s (vitest's transport-level channel that always reaches forks), read via `inject()` inside the test, skipping from the test body when no backend exists. Also add connector_definitions.api_type (a non-secret 'api'|'mcp'|'openapi' discriminator) to QUERYABLE_SCHEMA — it is a real DB column the drift test correctly flagged as missing; the large *_config blobs stay intentionally omitted.
📝 WalkthroughWalkthroughThis PR implements device-worker authorization for watcher pinning to prevent unauthorized cross-user/cross-organization device access, fixes a CI gap in schema drift testing by propagating database configuration to forked workers, and adds the missing ChangesDevice Worker Access Control and Test Fixes
Sequence DiagramsequenceDiagram
participant Handler as manage_watchers<br/>(create/update)
participant Assertion as assertDeviceWorkerAccess
participant DB as device_workers<br/>table
participant Decision as evaluateDeviceWorkerAccess
participant Error as ToolUserError
Handler->>Assertion: assertDeviceWorkerAccess(deviceWorkerId, caller)
alt deviceWorkerId is null/undefined
Assertion-->>Handler: return (skip validation)
else deviceWorkerId provided
Assertion->>DB: SELECT * FROM device_workers WHERE id = ?
alt device not found
Assertion->>Decision: evaluateDeviceWorkerAccess(null, caller)
Decision-->>Assertion: rejection reason
Assertion->>Error: throw 403 Forbidden
Error-->>Handler: authorization error
else device found
Assertion->>Decision: evaluateDeviceWorkerAccess(device, caller)
alt caller denied
Decision-->>Assertion: rejection reason
Assertion->>Error: throw 403 Forbidden
Error-->>Handler: authorization error
else caller allowed
Assertion-->>Handler: return (validation passed)
end
end
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Comment |
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/server/src/tools/admin/watcher-device-access.ts`:
- Around line 82-90: The code must validate/normalize deviceWorkerId before
attempting the UUID cast in the SQL query: ensure deviceWorkerId (and the
computed trimmed) are checked for being a valid UUID format and not just
whitespace; if invalid, either throw ToolUserError(400) or return a normalized
null/UUID for the callers (handleCreate/handleUpdate) to persist. Specifically,
add validation logic around the deviceWorkerId/trimmed variables used before the
sql`... WHERE id = ${trimmed}::uuid` so non-UUID strings or pure-whitespace are
rejected or normalized, and ensure handleCreate and handleUpdate use that
normalized value instead of persisting the original invalid string.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 055613a5-e932-48ad-9022-1ee0a38b6dae
📒 Files selected for processing (7)
packages/server/src/__tests__/integration/watchers/watchers-crud.test.tspackages/server/src/__tests__/setup/global-setup.tspackages/server/src/__tests__/unit/watcher-device-access.test.tspackages/server/src/tools/admin/manage_watchers.tspackages/server/src/tools/admin/watcher-device-access.tspackages/server/src/utils/__tests__/table-schema.test.tspackages/server/src/utils/table-schema.ts
| if (deviceWorkerId === undefined || deviceWorkerId === null) return; | ||
| const trimmed = deviceWorkerId.trim(); | ||
| if (!trimmed) return; | ||
|
|
||
| const rows = (await sql` | ||
| SELECT id, user_id, organization_id | ||
| FROM device_workers | ||
| WHERE id = ${trimmed}::uuid | ||
| LIMIT 1 |
There was a problem hiding this comment.
Reject or normalize malformed device_worker_id before the uuid cast.
Whitespace currently skips the lookup here, but handleCreate/handleUpdate still persist the original value, so " " falls through to the later uuid write path and 500s. Any other non-UUID string will also 500 at WHERE id = ${trimmed}::uuid. Please validate this input up front and either raise a ToolUserError(400) or return a normalized UUID/null that the callers persist.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/server/src/tools/admin/watcher-device-access.ts` around lines 82 -
90, The code must validate/normalize deviceWorkerId before attempting the UUID
cast in the SQL query: ensure deviceWorkerId (and the computed trimmed) are
checked for being a valid UUID format and not just whitespace; if invalid,
either throw ToolUserError(400) or return a normalized null/UUID for the callers
(handleCreate/handleUpdate) to persist. Specifically, add validation logic
around the deviceWorkerId/trimmed variables used before the sql`... WHERE id =
${trimmed}::uuid` so non-UUID strings or pure-whitespace are rejected or
normalized, and ensure handleCreate and handleUpdate use that normalized value
instead of persisting the original invalid string.
Two follow-ups from the PR #1058 multi-aspect review (issues #1060, #1061). Both are server-only; no owletto/submodule changes.
#1060 — Validate
device_worker_idownership inmanage_watchersA watcher pinned to a device worker runs its agent CLI on the device owner's machine.
manage_watcherscreate/update storeddevice_worker_idwithout verifying the caller could target that device, so a member-write actor could pin a watcher to another user's device — privilege escalation (unattended agent execution on someone else's machine).Fix: new
watcher-device-access.tsmirroring the shape ofwatcher-execution-config.ts:evaluateDeviceWorkerAccess(device, caller)— pure decision function (no DB). Allows the device owner (device.user_id === caller.userId), or an org owner/admin over a device attached to their org (device.organization_id === caller.organizationId). System/internal callers (isAuthenticated && userId===null && memberRole===null) are exempt, matchingassertValidExecutionConfig.assertDeviceWorkerAccess(sql, deviceWorkerId, caller)— DB-backed wrapper;undefined(unchanged) /null/empty (clear) pass without a lookup; rejection throwsToolUserError(403).Wired into
handleCreateandhandleUpdateright afterassertValidExecutionConfig.handleCreateFromVersiondoes not setdevice_worker_id, so it needs no gate.#1061 — Drift test dormant in CI +
connector_definitions.api_typemissing(a) The drift
describewas gated ondescribe.skipIf(!process.env.DATABASE_URL)evaluated at module load — timing-dependent under forked vitest workers, so it could silently self-skip and the gate was toothless. Fixed by havingglobal-setupprovide('databaseUrl', …)(vitest's transport-level cross-process channel) and gating the test oninject('databaseUrl')read inside the test body, skipping only when there is genuinely no backend (SKIP_TEST_DB_SETUP=1).(b)
connector_definitions.api_typeis a real DB column (text DEFAULT 'api', a non-secret'api'|'mcp'|'openapi'discriminator) missing from bothQUERYABLE_SCHEMAandINTENTIONALLY_OMITTED, so the drift test fails against a real DB. Added it to the cols list (parity with the other scalarconnector_definitionsmetadata columns); the large*_configJSONB blobs stay intentionally omitted.Validation (Node 22)
make build-packages+cd packages/server && bunx tsc --noEmit— clean (exit 0).bun test watcher-device-access.test.ts): 9 pass (owner/admin/member/system × owned/foreign/missing). Integration (watchers-crud.test.ts, embedded PG18): 12 pass incl. 4 new device-gate cases. Teeth proven: removingassertDeviceWorkerAccessmakes the 3 rejection cases fail (a foreign-org pin silently succeeds) → restored → green.createdb wf_test→dbmate up→ vitest with thatDATABASE_URL: 14 pass, drift case shows✓not↓); and under the embedded PG18 backend (14 pass). Teeth proven: removingapi_typemakes it fail against the real DB → restored → green.dropdb wf_testafter.make review BASE=origin/main: typecheck=0, unit=0, integration=0; pi verdict bug_free 88, 0 bugs, 0 blockers, tests_adequate=true.Closes #1060
Closes #1061
Summary by CodeRabbit
New Features
Tests
Chores