Skip to content

fix(server): watcher device-pin authz + table-schema drift test runs in CI#1062

Merged
buremba merged 2 commits into
mainfrom
feat/watcher-followups
May 26, 2026
Merged

fix(server): watcher device-pin authz + table-schema drift test runs in CI#1062
buremba merged 2 commits into
mainfrom
feat/watcher-followups

Conversation

@buremba
Copy link
Copy Markdown
Member

@buremba buremba commented May 26, 2026

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_id ownership in manage_watchers

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 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.ts mirroring the shape of watcher-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, matching assertValidExecutionConfig.
  • assertDeviceWorkerAccess(sql, deviceWorkerId, caller) — DB-backed wrapper; undefined (unchanged) / null/empty (clear) pass without a lookup; rejection throws ToolUserError (403).

Wired into handleCreate and handleUpdate right after assertValidExecutionConfig. handleCreateFromVersion does not set device_worker_id, so it needs no gate.

#1061 — Drift test dormant in CI + connector_definitions.api_type missing

(a) The drift describe was gated on describe.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 having global-setup provide('databaseUrl', …) (vitest's transport-level cross-process channel) and gating the test on inject('databaseUrl') read inside the test body, skipping only when there is genuinely no backend (SKIP_TEST_DB_SETUP=1).

(b) connector_definitions.api_type is a real DB column (text DEFAULT 'api', a non-secret 'api'|'mcp'|'openapi' discriminator) missing from both QUERYABLE_SCHEMA and INTENTIONALLY_OMITTED, so the drift test fails against a real DB. Added it to the cols list (parity with the other scalar connector_definitions metadata columns); the large *_config JSONB blobs stay intentionally omitted.

Validation (Node 22)

  • make build-packages + cd packages/server && bunx tsc --noEmit — clean (exit 0).
  • Validate device_worker_id ownership in manage_watchers (watcher device pinning) #1060 unit (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: removing assertDeviceWorkerAccess makes the 3 rejection cases fail (a foreign-org pin silently succeeds) → restored → green.
  • table-schema drift test is dormant in CI; connector_definitions.api_type missing from QUERYABLE_SCHEMA #1061 drift test proven to RUN (not skip) and PASS against real Homebrew PG17 + pgvector 0.8.1 (createdb wf_testdbmate up → vitest with that DATABASE_URL: 14 pass, drift case shows not ); and under the embedded PG18 backend (14 pass). Teeth proven: removing api_type makes it fail against the real DB → restored → green. dropdb wf_test after.
  • 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

    • Device access control for watchers: enforce ownership and authorization checks when pinning watchers to devices. Only authorized users can pin watchers to devices in their organization.
  • Tests

    • Extended watcher integration tests with device access control scenarios.
    • Added unit tests for device-worker access validation.
    • Improved test infrastructure for database configuration across forked workers.
  • Chores

    • Updated connector definitions schema allowlist.

Review Change Stack

buremba added 2 commits May 26, 2026 03:09
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.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 26, 2026

📝 Walkthrough

Walkthrough

This 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 api_type column to the schema allowlist.

Changes

Device Worker Access Control and Test Fixes

Layer / File(s) Summary
Device access control types and decision logic
packages/server/src/tools/admin/watcher-device-access.ts
Defines DeviceWorkerAccessCaller (caller identity) and DeviceWorkerOwnershipRow (device ownership) interfaces. Implements evaluateDeviceWorkerAccess pure function enforcing authorization rules: system/internal callers bypass checks; device owners are always allowed; org owners/admins are allowed when the device belongs to their organization; all other roles receive a rejection reason.
Device worker access assertion with database lookup
packages/server/src/tools/admin/watcher-device-access.ts
Implements assertDeviceWorkerAccess that conditionally queries device_workers by UUID (skipping for null/undefined), invokes the pure decision function, and throws ToolUserError with HTTP 403 when access is denied.
Device access enforcement in watcher create/update
packages/server/src/tools/admin/manage_watchers.ts
Integrates assertDeviceWorkerAccess into manage_watchers create and update handlers to validate device pin authorization before persisting watcher changes, with distinction between undefined (leave pin unchanged) and null (clear pin).
Unit tests for device access decisions
packages/server/src/__tests__/unit/watcher-device-access.test.ts
Comprehensive unit test suite for evaluateDeviceWorkerAccess covering own-device pins, foreign-device authorization by role (member vs. owner/admin), rejection when device is missing, and system caller bypass behavior.
Integration tests for device-worker-id authorization
packages/server/src/__tests__/integration/watchers/watchers-crud.test.ts
End-to-end test coverage via watchers.manage SDK exercising device access gates: positive case where org owner pins a foreign-user device within the same organization; negative cases for cross-organization device pins, nonexistent device UUIDs, and update re-pinning violations.
Test infrastructure: database URL propagation to forked workers
packages/server/src/__tests__/setup/global-setup.ts, packages/server/src/utils/__tests__/table-schema.test.ts
Updates Vitest global setup to augment ProvidedContext with databaseUrl field and propagate the resolved URL to forked workers via provide(). Updates table-schema drift test to consume the injected URL at runtime instead of module-load-time checks, enabling proper drift detection in CI.
Schema: add connector_definitions.api_type to QUERYABLE_SCHEMA
packages/server/src/utils/table-schema.ts
Adds api_type non-secret scalar column to connector_definitions allowlist in QUERYABLE_SCHEMA with documentation that *_config blobs remain intentionally omitted.

Sequence Diagram

sequenceDiagram
  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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • lobu-ai/lobu#811: Introduces the watchers.device_worker_id column/FK/index in the database schema; this PR implements the authorization logic and validation for that schema field.

Poem

🐰 A watcher pins a device with care,
But which machines can they access there?
The owner checks the organization,
Device and caller validation!
Schema drift in CI now shows true. 🎯

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: device-pin authorization validation and drift test fixes for table-schema.
Description check ✅ Passed The description provides thorough context for both issues (#1060 and #1061), explains the fixes, implementation details, and comprehensive validation results.
Linked Issues check ✅ Passed All coding requirements from #1060 (device-worker ownership validation in create/update) and #1061 (drift test gating fix and api_type addition) are fully implemented and tested.
Out of Scope Changes check ✅ Passed All changes are scoped to the two linked issues: device-worker access control, drift test infrastructure, and schema allowlist updates; no extraneous modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/watcher-followups

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter
Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 9bd5d10 and cb037b3.

📒 Files selected for processing (7)
  • packages/server/src/__tests__/integration/watchers/watchers-crud.test.ts
  • packages/server/src/__tests__/setup/global-setup.ts
  • packages/server/src/__tests__/unit/watcher-device-access.test.ts
  • packages/server/src/tools/admin/manage_watchers.ts
  • packages/server/src/tools/admin/watcher-device-access.ts
  • packages/server/src/utils/__tests__/table-schema.test.ts
  • packages/server/src/utils/table-schema.ts

Comment on lines +82 to +90
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

@buremba buremba merged commit 75c52a0 into main May 26, 2026
23 of 24 checks passed
@buremba buremba deleted the feat/watcher-followups branch May 26, 2026 02:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants