Skip to content

Commit 3401053

Browse files
committed
feat(routing): Phase 3 — Space lock as narrowing filter
Autonomous decisions: - ON DELETE SET NULL for locked_space_id FK — matches machine_id and other inbound_clients Space FK conventions - Tier 0 filters binding/grant results post-lookup rather than adding repo-level space predicates — keeps existing tier lookup order intact while enforcing the lock boundary Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 9af05c0 commit 3401053

8 files changed

Lines changed: 283 additions & 40 deletions

File tree

apps/desktop/src-tauri/src/commands/oauth.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,7 @@ fn generate_api_key() -> (String, String, String) {
717717
pub async fn register_api_key_client(
718718
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
719719
name: String,
720+
locked_space_id: Option<String>,
720721
) -> Result<RegisteredApiKeyClient, String> {
721722
let app_state = gateway_state.read().await;
722723
let Some(ref gw_state) = app_state.gateway_state else {
@@ -763,6 +764,20 @@ pub async fn register_api_key_client(
763764
.await
764765
.map_err(|e| format!("Failed to create client: {}", e))?;
765766

767+
let locked_space = locked_space_id
768+
.as_deref()
769+
.map(str::trim)
770+
.filter(|s| !s.is_empty())
771+
.map(|s| {
772+
uuid::Uuid::parse_str(s).map_err(|_| format!("Invalid locked_space_id: {s}"))
773+
})
774+
.transpose()?;
775+
if let Some(space_id) = locked_space {
776+
repo.set_locked_space(&client_id, Some(space_id))
777+
.await
778+
.map_err(|e| format!("Failed to set Space lock: {}", e))?;
779+
}
780+
766781
let (key_id, plaintext, key_prefix) = generate_api_key();
767782
repo.create_api_key(&key_id, &client_id, &plaintext, &key_prefix, None, None)
768783
.await
@@ -782,7 +797,7 @@ pub async fn register_api_key_client(
782797
Ok(RegisteredApiKeyClient {
783798
client_id,
784799
client_name: trimmed.to_string(),
785-
locked_space_id: None,
800+
locked_space_id: locked_space.map(|id| id.to_string()),
786801
api_key: plaintext,
787802
key_prefix,
788803
})
@@ -825,10 +840,16 @@ pub async fn create_client_api_key(
825840
.await
826841
.map_err(|e| format!("Failed to create API key: {}", e))?;
827842

843+
let locked_space_id = repo
844+
.get_locked_space(&client_id)
845+
.await
846+
.map_err(|e| format!("Failed to read Space lock: {}", e))?
847+
.map(|id| id.to_string());
848+
828849
Ok(RegisteredApiKeyClient {
829850
client_id,
830851
client_name: client.client_name,
831-
locked_space_id: None,
852+
locked_space_id,
832853
api_key: plaintext,
833854
key_prefix,
834855
})

apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useState } from 'react';
1414
import { AlertTriangle, Check, Copy, KeyRound, Loader2, ShieldCheck, X } from 'lucide-react';
1515
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@mcpmux/ui';
1616
import { registerApiKeyClient, type RegisteredApiKeyClient } from '@/lib/api/gateway';
17+
import { useSpaces } from '@/stores';
1718

1819
interface RegisterApiKeyClientModalProps {
1920
onClose: () => void;
@@ -28,7 +29,9 @@ export function RegisterApiKeyClientModal({
2829
onClose,
2930
onRegistered,
3031
}: RegisterApiKeyClientModalProps) {
32+
const spaces = useSpaces();
3133
const [name, setName] = useState('');
34+
const [lockedSpaceId, setLockedSpaceId] = useState('');
3235
const [isSubmitting, setIsSubmitting] = useState(false);
3336
const [error, setError] = useState<string | null>(null);
3437
const [result, setResult] = useState<RegisteredApiKeyClient | null>(null);
@@ -43,7 +46,10 @@ export function RegisterApiKeyClientModal({
4346
setIsSubmitting(true);
4447
setError(null);
4548
try {
46-
const client = await registerApiKeyClient(trimmed);
49+
const client = await registerApiKeyClient(
50+
trimmed,
51+
lockedSpaceId.trim() ? lockedSpaceId.trim() : null
52+
);
4753
setResult(client);
4854
} catch (e) {
4955
setError(e instanceof Error ? e.message : String(e));
@@ -162,6 +168,34 @@ export function RegisterApiKeyClientModal({
162168
/>
163169
</div>
164170

171+
<div>
172+
<label
173+
htmlFor="api-key-locked-space"
174+
className="mb-1.5 block text-sm font-medium"
175+
>
176+
Lock to a Space
177+
</label>
178+
<select
179+
id="api-key-locked-space"
180+
data-testid="register-api-key-locked-space"
181+
value={lockedSpaceId}
182+
onChange={(e) => setLockedSpaceId(e.target.value)}
183+
className="w-full rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-3.5 py-2.5 text-sm transition-all focus:border-[rgb(var(--accent))] focus:outline-none focus:ring-2 focus:ring-[rgb(var(--accent))]/40"
184+
>
185+
<option value="">No lock — resolve across all Spaces</option>
186+
{spaces.map((space) => (
187+
<option key={space.id} value={space.id}>
188+
{space.icon ? `${space.icon} ` : ''}
189+
{space.name}
190+
</option>
191+
))}
192+
</select>
193+
<p className="mt-1.5 text-xs text-[rgb(var(--muted))]">
194+
Optional. When set, the client can only use bindings or grants inside this Space.
195+
It still needs an explicit mapping — lock alone grants no tools.
196+
</p>
197+
</div>
198+
165199
<div className="flex items-start gap-3 rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] p-3.5">
166200
<ShieldCheck className="mt-0.5 h-5 w-5 flex-shrink-0 text-[rgb(var(--accent))]" />
167201
<p className="text-xs text-[rgb(var(--muted))]">

apps/desktop/src/lib/api/gateway.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,8 +315,14 @@ export interface ApiKeyInfo {
315315
* Register a pre-approved client authenticated by an API key.
316316
* The returned key is shown once and never retrievable again.
317317
*/
318-
export async function registerApiKeyClient(name: string): Promise<RegisteredApiKeyClient> {
319-
return apiCall('register_api_key_client', { name });
318+
export async function registerApiKeyClient(
319+
name: string,
320+
lockedSpaceId?: string | null
321+
): Promise<RegisteredApiKeyClient> {
322+
return apiCall('register_api_key_client', {
323+
name,
324+
lockedSpaceId: lockedSpaceId ?? null,
325+
});
320326
}
321327

322328
/** Issue an additional API key for an existing client (rotation). Shown once. */

crates/mcpmux-gateway/src/services/feature_set_resolver.rs

Lines changed: 72 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,19 @@ impl FeatureSetResolverService {
397397
}
398398
}
399399

400+
/// Whether a binding is eligible under an optional Space lock.
401+
fn binding_matches_space_lock(
402+
binding: &mcpmux_core::WorkspaceBinding,
403+
space_lock: Option<Uuid>,
404+
) -> bool {
405+
space_lock.is_none_or(|lock| binding.space_id == lock)
406+
}
407+
408+
/// Space id used for Unbound / grant lookups when a lock is active.
409+
fn unbound_space_id(space_lock: Option<Uuid>, fallback: Uuid) -> Uuid {
410+
space_lock.unwrap_or(fallback)
411+
}
412+
400413
/// Borrow the session-roots registry. The notifier uses this to GC
401414
/// dead sessions out of the registry when reaping the corresponding
402415
/// peer entries — keeping both stores in sync.
@@ -430,6 +443,14 @@ impl FeatureSetResolverService {
430443
}
431444
};
432445

446+
// Tier 0 — Space lock narrows all subsequent tiers to one Space.
447+
// It never grants tools by itself; no in-Space match still → Unbound.
448+
let space_lock = match client_id {
449+
Some(cid) => self.client_repo.get_locked_space(cid).await?,
450+
None => None,
451+
};
452+
let deny_space_id = Self::unbound_space_id(space_lock, default_space_id);
453+
433454
// Tier 1 / 1b / 1c — branches on roots-capable + roots-arrived state.
434455
if let Some(sid) = session_id {
435456
let roots = self.session_roots.get(sid);
@@ -468,27 +489,35 @@ impl FeatureSetResolverService {
468489
.find_binding_for_roots(&reported_roots, client_id, request_machine_id)
469490
.await?
470491
{
492+
if Self::binding_matches_space_lock(&binding, space_lock) {
493+
debug!(
494+
workspace_root = %binding.workspace_root,
495+
space_id = %binding.space_id,
496+
feature_sets = ?binding.feature_set_ids,
497+
"[FeatureSetResolver] resolved via WorkspaceBinding",
498+
);
499+
return Ok(ResolvedFeatureSet {
500+
feature_set_ids: binding.feature_set_ids,
501+
space_id: Some(binding.space_id),
502+
source: ResolutionSource::WorkspaceBinding,
503+
});
504+
}
471505
debug!(
472-
workspace_root = %binding.workspace_root,
473-
space_id = %binding.space_id,
474-
feature_sets = ?binding.feature_set_ids,
475-
"[FeatureSetResolver] resolved via WorkspaceBinding",
506+
binding_space = %binding.space_id,
507+
?space_lock,
508+
"[FeatureSetResolver] path binding outside locked Space — ignored",
476509
);
477-
return Ok(ResolvedFeatureSet {
478-
feature_set_ids: binding.feature_set_ids,
479-
space_id: Some(binding.space_id),
480-
source: ResolutionSource::WorkspaceBinding,
481-
});
482510
}
483-
// Tier 1b: had roots, no binding. The folder is unmapped —
484-
// deny by default. Scope `space_id` to the Space whose base
485-
// directory claims the root (longest-prefix), if any; otherwise
486-
// the global default Space. Upstream emits WorkspaceNeedsBinding
487-
// so the user can attach an explicit binding.
488-
let target_space = self
489-
.space_for_roots(&reported_roots)
490-
.await?
491-
.unwrap_or(default_space_id);
511+
// Tier 1b: had roots, no binding (or binding outside lock).
512+
// The folder is unmapped — deny by default. When locked, scope
513+
// `space_id` to the locked Space; otherwise longest-prefix base dir.
514+
let target_space = if space_lock.is_some() {
515+
deny_space_id
516+
} else {
517+
self.space_for_roots(&reported_roots)
518+
.await?
519+
.unwrap_or(default_space_id)
520+
};
492521
debug!(
493522
%target_space,
494523
scoped_by_base_dir = target_space != default_space_id,
@@ -525,7 +554,7 @@ impl FeatureSetResolverService {
525554
);
526555
return Ok(ResolvedFeatureSet {
527556
feature_set_ids: vec![],
528-
space_id: Some(default_space_id),
557+
space_id: Some(deny_space_id),
529558
source: ResolutionSource::PendingRoots,
530559
});
531560
}
@@ -538,7 +567,7 @@ impl FeatureSetResolverService {
538567
capability = ?roots_capable_known,
539568
"[FeatureSetResolver] pending-roots grace lapsed, no root reported — Unbound",
540569
);
541-
return Ok(self.unbound(default_space_id));
570+
return Ok(self.unbound(deny_space_id));
542571
}
543572
}
544573

@@ -548,17 +577,24 @@ impl FeatureSetResolverService {
548577
.find_binding_for_client_id(cid, request_machine_id)
549578
.await?
550579
{
580+
if Self::binding_matches_space_lock(&binding, space_lock) {
581+
debug!(
582+
client_id = %cid,
583+
space_id = %binding.space_id,
584+
feature_sets = ?binding.feature_set_ids,
585+
"[FeatureSetResolver] resolved via id-type WorkspaceBinding",
586+
);
587+
return Ok(ResolvedFeatureSet {
588+
feature_set_ids: binding.feature_set_ids,
589+
space_id: Some(binding.space_id),
590+
source: ResolutionSource::WorkspaceBinding,
591+
});
592+
}
551593
debug!(
552-
client_id = %cid,
553-
space_id = %binding.space_id,
554-
feature_sets = ?binding.feature_set_ids,
555-
"[FeatureSetResolver] resolved via id-type WorkspaceBinding",
594+
binding_space = %binding.space_id,
595+
?space_lock,
596+
"[FeatureSetResolver] id binding outside locked Space — ignored",
556597
);
557-
return Ok(ResolvedFeatureSet {
558-
feature_set_ids: binding.feature_set_ids,
559-
space_id: Some(binding.space_id),
560-
source: ResolutionSource::WorkspaceBinding,
561-
});
562598
}
563599
}
564600

@@ -567,25 +603,26 @@ impl FeatureSetResolverService {
567603
// (the desktop UI's preview HTTP path lands here too). Consult the
568604
// per-client grant table.
569605
if let Some(cid) = client_id {
606+
let grant_space_id = deny_space_id;
570607
// Propagate storage errors instead of treating them as "no
571608
// grants": a transient DB failure must surface as a request
572609
// error, not a silent deny (which would also record a `None`
573610
// fingerprint and fire a spurious Deny→Grant flip-notification
574611
// cycle once the error clears).
575612
let grants = self
576613
.client_repo
577-
.get_grants_for_space(cid, &default_space_id.to_string())
614+
.get_grants_for_space(cid, &grant_space_id.to_string())
578615
.await?;
579616
if !grants.is_empty() {
580617
debug!(
581618
client_id = %cid,
582-
space_id = %default_space_id,
619+
space_id = %grant_space_id,
583620
grant_count = grants.len(),
584621
"[FeatureSetResolver] resolved via ClientGrant",
585622
);
586623
return Ok(ResolvedFeatureSet {
587624
feature_set_ids: grants,
588-
space_id: Some(default_space_id),
625+
space_id: Some(grant_space_id),
589626
source: ResolutionSource::ClientGrant,
590627
});
591628
}
@@ -595,10 +632,11 @@ impl FeatureSetResolverService {
595632
// tools are appended unconditionally by the request handler regardless,
596633
// so the LLM can always self-bind / ask the user for a grant from here.
597634
debug!(
598-
space_id = %default_space_id,
635+
space_id = %deny_space_id,
599636
?client_id,
637+
?space_lock,
600638
"[FeatureSetResolver] no roots + no id binding + no grants — Unbound",
601639
);
602-
Ok(self.unbound(default_space_id))
640+
Ok(self.unbound(deny_space_id))
603641
}
604642
}

crates/mcpmux-storage/src/database.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,11 @@ const MIGRATIONS: &[Migration] = &[
218218
name: "workspace_binding_type",
219219
sql: include_str!("migrations/037_workspace_binding_type.sql"),
220220
},
221+
Migration {
222+
version: 38,
223+
name: "inbound_client_locked_space",
224+
sql: include_str!("migrations/038_inbound_client_locked_space.sql"),
225+
},
221226
];
222227

223228
/// SQLite database wrapper.
@@ -758,7 +763,7 @@ mod tests {
758763
|row| row.get(0),
759764
)
760765
.unwrap();
761-
assert_eq!(version, 37);
766+
assert_eq!(version, 38);
762767

763768
let v16_name: String = db
764769
.conn
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- Migration 038: Optional locked_space_id on inbound_clients.
2+
--
3+
-- Confines an API-key (or other inbound) client to one Space. The resolver
4+
-- treats this as a narrowing filter — bindings/grants outside the locked
5+
-- Space are ignored; no in-Space match still resolves to Unbound.
6+
7+
ALTER TABLE inbound_clients ADD COLUMN locked_space_id TEXT REFERENCES spaces(id) ON DELETE SET NULL;
8+
9+
CREATE INDEX IF NOT EXISTS idx_inbound_clients_locked_space_id ON inbound_clients(locked_space_id);

crates/mcpmux-storage/src/repositories/inbound_client_repository.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,37 @@ impl InboundClientRepository {
469469
Ok(machine_id_str.and_then(|s| Uuid::parse_str(&s).ok()))
470470
}
471471

472+
/// Read the Space lock assigned to an inbound client, if any.
473+
pub async fn get_locked_space(&self, client_id: &str) -> Result<Option<Uuid>> {
474+
let db = self.db.lock().await;
475+
let conn = db.connection();
476+
let locked_space_str: Option<String> = conn
477+
.query_row(
478+
"SELECT locked_space_id FROM inbound_clients WHERE client_id = ?1",
479+
params![client_id],
480+
|row| row.get(0),
481+
)
482+
.ok();
483+
Ok(locked_space_str.and_then(|s| Uuid::parse_str(&s).ok()))
484+
}
485+
486+
/// Assign or clear the Space lock for an inbound client.
487+
pub async fn set_locked_space(&self, client_id: &str, space_id: Option<Uuid>) -> Result<()> {
488+
let db = self.db.lock().await;
489+
let conn = db.connection();
490+
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
491+
let space_id_str = space_id.map(|id| id.to_string());
492+
conn.execute(
493+
"UPDATE inbound_clients SET locked_space_id = ?1, updated_at = ?2 WHERE client_id = ?3",
494+
params![space_id_str, now, client_id],
495+
)?;
496+
debug!(
497+
"[OAuth] Set locked_space_id for client {}: {:?}",
498+
client_id, space_id
499+
);
500+
Ok(())
501+
}
502+
472503
/// Assign or clear the machine id for an inbound OAuth client.
473504
pub async fn set_machine_id(&self, client_id: &str, machine_id: Option<Uuid>) -> Result<()> {
474505
let db = self.db.lock().await;

0 commit comments

Comments
 (0)