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
35 changes: 35 additions & 0 deletions src/interface/http/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { pathParam } from './connections.ts'
import {
beginConnection,
disconnect,
setApiKey,
type ConnectionDeps,
} from '../../application/connections.ts'

Expand Down Expand Up @@ -182,6 +183,10 @@ export function adminRoutes(deps: AdminDeps): Router {
grant: adapter.grantId,
maturity: adapter.maturity,
scopes: adapter.scopes,
// The portal has to know whether to open a consent window or ask for
// a secret, and the prefix does not say. Without this it offers
// Connect for every provider and the api_key ones answer 400.
credential: adapter.credential ?? 'oauth',
connected: enabled.includes(adapter.prefix),
})),
request_id: req.requestId,
Expand Down Expand Up @@ -219,6 +224,36 @@ export function adminRoutes(deps: AdminDeps): Router {
},
)

// The service-token twin of PUT /v1/connections/:prefix/key. The portal holds
// the service token and never the workspace credential, so without this route
// there is no way for it to connect a provider that takes a key at all.
router.put(
'/v1/admin/workspaces/:workspaceId/connections/:prefix/key',
async (req, res, next) => {
try {
const workspaceId = workspaceParam(req)
await readWorkspace(deps.pool, workspaceId)
const prefix = pathParam(req, 'prefix')
if (typeof req.body?.api_key !== 'string') {
throw new GatewayError('invalid_arguments', 'api_key must be a string')
}
await setApiKey(deps.connections, { workspaceId, prefix, key: req.body.api_key })
// The key itself is never echoed back, not even its tail, so the audit
// row records that it changed and nothing about what it is.
await recordAudit(deps.pool, {
workspaceId,
actor: 'service',
action: 'connection.key_set',
target: prefix,
requestId: req.requestId,
})
res.json({ connected: prefix, request_id: req.requestId })
} catch (err) {
next(err)
}
},
)

router.delete('/v1/admin/workspaces/:workspaceId/connections/:prefix', async (req, res, next) => {
try {
const workspaceId = workspaceParam(req)
Expand Down
78 changes: 78 additions & 0 deletions test/admin-connections.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterAll, describe, expect, it } from 'vitest'
import { closeTestServers, startTestServer, testConfig } from './helpers/server.ts'
import { bootRegistry } from '../src/adapters/providers/boot.ts'
import { testPool } from './helpers/db.ts'
import { json } from './helpers/http.ts'

Expand Down Expand Up @@ -167,3 +168,80 @@ describe('admin connections', () => {
expect((await json(res)).error.code).toBe('tool_not_found')
})
})

// The portal drove every provider through authorize, so discord answered 400
// and the portal turned it into a 500. It needs to read the credential kind off
// the list, and it needs somewhere to put the key once it asks for one.
describe('admin connections for a provider that takes a key', () => {
const KEY = 'MTIzNDU2Nzg5.Gabcde.a-real-looking-bot-token'

const putKey = (base: string, workspaceId: string, prefix: string, api_key: unknown) =>
fetch(`${base}/v1/admin/workspaces/${workspaceId}/connections/${prefix}/key`, {
method: 'PUT',
headers: svc,
body: JSON.stringify({ api_key }),
})

it('names the credential kind so consent and key providers can be told apart', async () => {
// bootRegistry leaves out a provider with no client id, so github is only
// in the list when one is configured.
const { base, workspaceId } = await startTestServer({
enable: [],
overrides: {
...fakeVendor,
config: { ...testConfig, serviceToken: TOKEN, dashboardUrl: DASHBOARD },
registry: bootRegistry({ ...process.env, GITHUB_CLIENT_ID: 'cid' }),
},
})
const listed = await json(
await fetch(`${base}/v1/admin/workspaces/${workspaceId}/connections`, { headers: svc }),
)
expect(listed.connections).toContainEqual(
expect.objectContaining({ provider: 'discord', credential: 'api_key', connected: false }),
)
expect(listed.connections).toContainEqual(
expect.objectContaining({ provider: 'github', credential: 'oauth' }),
)
})

it('stores the key encrypted, turns the provider on and never echoes it back', async () => {
const { base, pool, workspaceId } = await start([])
const res = await putKey(base, workspaceId, 'discord', KEY)
expect(res.status).toBe(200)
const body = await json(res)
expect(body).toMatchObject({ connected: 'discord' })
expect(JSON.stringify(body)).not.toContain(KEY.slice(0, 12))

const stored = await pool.query(
'SELECT access_token FROM grants WHERE workspace_id = $1 AND grant_id = $2',
[workspaceId, 'discord'],
)
expect(stored.rowCount).toBe(1)
expect(JSON.stringify(stored.rows[0].access_token)).not.toContain(KEY)

const listed = await json(
await fetch(`${base}/v1/admin/workspaces/${workspaceId}/connections`, { headers: svc }),
)
expect(listed.connections).toContainEqual(
expect.objectContaining({ provider: 'discord', connected: true }),
)

const audit = await pool.query(
'SELECT actor, action, target FROM audit_log WHERE workspace_id = $1 ORDER BY id',
[workspaceId],
)
expect(audit.rows).toEqual([
{ actor: 'service', action: 'connection.key_set', target: 'discord' },
])
})

it('refuses a key for a provider that does not take one, a non string, a blank one and an unknown workspace', async () => {
const { base, workspaceId } = await start([])
expect((await putKey(base, workspaceId, 'fake', KEY)).status).toBe(400)
expect((await putKey(base, workspaceId, 'discord', 42)).status).toBe(400)
expect((await putKey(base, workspaceId, 'discord', ' ')).status).toBe(400)
expect(
(await putKey(base, '00000000-0000-0000-0000-000000000000', 'discord', KEY)).status,
).toBe(404)
})
})
Loading