Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions control-api/test/services.rpcHostAccessAuthorizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { K8sGateway } from '../src/k8s.js'
import type { RpcAccessClaims } from '../src/profileTypes.js'
import { authorizeRpcHostAccess } from '../src/services/access/rpcHostAccessAuthorizer.js'
import { signRpcAccessToken, verifyRpcAccessToken } from '../src/utils/auth/rpcAuthToken.js'

const CLAIMS: RpcAccessClaims = {
sub: 'user-1',
Expand Down Expand Up @@ -48,6 +49,81 @@ function dependencies(
}

describe('authorizeRpcHostAccess', () => {
function issuedClaims(hostRefs: string[]): RpcAccessClaims {
const token = signRpcAccessToken({
sub: CLAIMS.sub,
typ: CLAIMS.typ,
teamId: CLAIMS.teamId,
role: CLAIMS.role,
scopes: CLAIMS.scopes,
hostRefs,
jti: `issued-${hostRefs.join('-') || 'empty'}`,
})
const verified = verifyRpcAccessToken(token)
if (!verified) throw new Error('Control API rejected its own signed RPC token')
return verified
}

it('intersects signed Host refs with live direct grants across grants and revocation', async () => {
const hosts = [
{ metadata: { name: 'host-a' }, spec: { enabled: true } },
{ metadata: { name: 'host-b' }, spec: { enabled: true } },
]
const tokenA = issuedClaims(['host-a'])
const liveBoth = dependencies({ userAgents: ['host-a', 'host-b'], hosts })
await expect(
authorizeRpcHostAccess(liveBoth.gateway, tokenA, 'user-1', 'host-a', liveBoth.directory)
).resolves.toMatchObject({ authorized: true })
await expect(
authorizeRpcHostAccess(liveBoth.gateway, tokenA, 'user-1', 'host-b', liveBoth.directory)
).resolves.toEqual({ authorized: false, reason: 'host_claim_missing' })

const reminted = issuedClaims(['host-a', 'host-b'])
await expect(
authorizeRpcHostAccess(liveBoth.gateway, reminted, 'user-1', 'host-b', liveBoth.directory)
).resolves.toMatchObject({ authorized: true })
const revokedB = dependencies({ userAgents: ['host-a'], hosts })
await expect(
authorizeRpcHostAccess(revokedB.gateway, reminted, 'user-1', 'host-b', revokedB.directory)
).resolves.toEqual({ authorized: false, reason: 'directory_grant_missing' })
await expect(
authorizeRpcHostAccess(revokedB.gateway, reminted, 'user-1', 'host-a', revokedB.directory)
).resolves.toMatchObject({ authorized: true })
})

it('applies the signed ceiling to team grants and rejects subject substitution', async () => {
const claims = issuedClaims(['host-a', 'host-b'])
const liveTeam = dependencies({
userAgents: [],
teamAgents: ['host-b'],
hosts: [{ metadata: { name: 'host-b' }, spec: { enabled: true } }],
})
await expect(
authorizeRpcHostAccess(liveTeam.gateway, claims, 'user-1', 'host-b', liveTeam.directory)
).resolves.toMatchObject({ authorized: true })
await expect(
authorizeRpcHostAccess(
liveTeam.gateway,
claims,
'different-user',
'host-b',
liveTeam.directory
)
).resolves.toEqual({ authorized: false, reason: 'subject_mismatch' })
expect(
verifyRpcAccessToken(
signRpcAccessToken({
sub: CLAIMS.sub,
typ: CLAIMS.typ,
teamId: CLAIMS.teamId,
role: CLAIMS.role,
scopes: CLAIMS.scopes,
hostRefs: [],
jti: 'issued-empty',
})
)
).toBeNull()
})
it('returns a typed subject mismatch before directory or Kubernetes access', async () => {
const { gateway, directory } = dependencies()
await expect(
Expand Down
30 changes: 30 additions & 0 deletions desktop-app/src/__tests__/rpcProxyClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,26 @@ describe('RpcProxyClient.listSessions', () => {
vi.restoreAllMocks()
})

it('surfaces W1 400 instead of treating it as absent session or available Host', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: async () => 'Invalid hostRef',
})
)
await expect(client.listSessions('token', 'invalid_host')).rejects.toMatchObject({
status: 400,
})
await expect(
client.loadSessionMessages('token', 'invalid_host', 'agent', 'chat')
).rejects.toMatchObject({ status: 400 })
await expect(
client.getContextBreakdown('token', 'invalid_host', 'agent', 'chat')
).rejects.toMatchObject({ status: 400 })
})

it.each(['.', '..', 'host/name', 'host\\name', 'host\nforged', 'x'.repeat(501)])(
'rejects an unsafe host path before issuing a request: %s',
async hostRef => {
Expand Down Expand Up @@ -874,6 +894,16 @@ describe('RpcProxyClient.getHostModels', () => {
await expect(client.getHostModels('t', 'h', 'c')).resolves.toBeNull()
})

it('does not treat W1 Host-ref 400 as an unavailable model-list feature', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 400, text: async () => 'Invalid hostRef' })
)
await expect(client.getHostModels('t', 'invalid_host', 'c')).rejects.toMatchObject({
status: 400,
})
})

it('throws on a genuine server error (500)', async () => {
vi.stubGlobal(
'fetch',
Expand Down
2 changes: 1 addition & 1 deletion external-rest-api/src/releaseManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type ReleaseManifest = {
export const releaseManifest: ReleaseManifest = {
releaseId: 'v0.10.0',
externalRestApiVersion: '0.1.113',
rpcProxyVersion: '0.1.121',
rpcProxyVersion: '0.1.123',
desktopVersion: '0.10.0',
minimumDesktopVersion: '0.1.252',
}
4 changes: 2 additions & 2 deletions rpc-proxy/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion rpc-proxy/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clerum-rpc-proxy",
"version": "0.1.121",
"version": "0.1.123",
"description": "External-facing RPC proxy for tenant-scoped MCP access",
"main": "dist/main.js",
"engines": {
Expand Down
Loading
Loading