feat(engine): API Key read access for events, objects, projections (#36)
- Auth middleware now accepts API Key for GET on data endpoints (/events, /objects, /projections, /event-defs, /projection-defs, /object-defs) - Admin write ops (POST defs, reactions, api-keys) still require API_TOKEN - Valid API Key + admin op → 403 Forbidden - Invalid token → 401 Unauthorized - Add 5 tests for API Key read access (#37) closes #36
This commit is contained in:
parent
8e0f1e3a28
commit
bc12a4bb18
@ -2250,6 +2250,70 @@ describe('API Key Management', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// API Key Read Access (#36)
|
||||
// ============================================
|
||||
|
||||
describe('API Key Read Access', () => {
|
||||
let apiKey: string
|
||||
let agentId: number
|
||||
|
||||
beforeEach(async () => {
|
||||
// Need object-def 'agent' first
|
||||
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN })
|
||||
const agentRes = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN })
|
||||
agentId = (await agentRes.json()).id
|
||||
|
||||
const keyRes = await app.fetch(
|
||||
req('POST', '/api-keys', { name: 'read-test', allowed_events: ['task_created'] }),
|
||||
{ DB: db, API_TOKEN },
|
||||
)
|
||||
apiKey = (await keyRes.json()).key
|
||||
})
|
||||
|
||||
it('GET /events with API Key → 200', async () => {
|
||||
const res = await app.fetch(
|
||||
req('GET', '/events?ref=' + agentId, undefined, apiKey),
|
||||
{ DB: db, API_TOKEN },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const json = await res.json()
|
||||
expect(json.events).toBeDefined()
|
||||
})
|
||||
|
||||
it('GET /objects/:id with API Key → 200', async () => {
|
||||
const res = await app.fetch(
|
||||
req('GET', `/objects/${agentId}`, undefined, apiKey),
|
||||
{ DB: db, API_TOKEN },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('GET /event-defs with API Key → 200', async () => {
|
||||
const res = await app.fetch(
|
||||
req('GET', '/event-defs', undefined, apiKey),
|
||||
{ DB: db, API_TOKEN },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('POST /event-defs with API Key → 403', async () => {
|
||||
const res = await app.fetch(
|
||||
req('POST', '/event-defs', { name: 'blocked_type', schema: { properties: { x: { type: 'string' } } } }, apiKey),
|
||||
{ DB: db, API_TOKEN },
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('GET /api-keys with API Key → 403', async () => {
|
||||
const res = await app.fetch(
|
||||
req('GET', '/api-keys', undefined, apiKey),
|
||||
{ DB: db, API_TOKEN },
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Request Logs (#221)
|
||||
// ============================================
|
||||
|
||||
@ -145,11 +145,47 @@ app.get('/schema', async (c) => {
|
||||
return c.json(schema)
|
||||
})
|
||||
|
||||
// Auth middleware for all routes except health, schema, ui, and POST /events (which has its own dual auth)
|
||||
// Auth middleware: API_TOKEN for admin ops, API Key allowed for reads + POST /events
|
||||
// Read-only paths that API Keys can access
|
||||
const API_KEY_READABLE_PREFIXES = ['/events', '/objects', '/projections', '/event-defs', '/projection-defs', '/object-defs']
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
if (c.req.path === '/health' || c.req.path === '/schema' || c.req.path === '/favicon.ico' || c.req.path.startsWith('/ui')) return next()
|
||||
if (c.req.method === 'POST' && c.req.path === '/events') return next()
|
||||
return bearerAuth(c.env.API_TOKEN)(c, next)
|
||||
|
||||
const authHeader = c.req.header('Authorization')
|
||||
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
|
||||
if (!bearerToken) {
|
||||
return apiError(c, 401, ErrorCode.UNAUTHORIZED, 'Missing or invalid Authorization header')
|
||||
}
|
||||
|
||||
// Admin token: full access
|
||||
if (bearerToken === c.env.API_TOKEN) return next()
|
||||
|
||||
// API Key: read-only access to data endpoints
|
||||
if (c.req.method === 'GET') {
|
||||
const isReadable = API_KEY_READABLE_PREFIXES.some(p => c.req.path.startsWith(p))
|
||||
if (isReadable) {
|
||||
const result = await validateApiKey(c.env.DB, bearerToken)
|
||||
if (result.valid) {
|
||||
if (result.apiKey) {
|
||||
c.set('apiKeyId', result.apiKey.id)
|
||||
c.set('apiKeyName', result.apiKey.name)
|
||||
}
|
||||
return next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a valid API Key trying admin ops → 403
|
||||
const keyCheck = await validateApiKey(c.env.DB, bearerToken)
|
||||
if (keyCheck.valid) {
|
||||
return apiError(c, 403, ErrorCode.FORBIDDEN, 'API key cannot perform admin operations')
|
||||
}
|
||||
|
||||
// Invalid token entirely → 401
|
||||
return apiError(c, 401, ErrorCode.UNAUTHORIZED, 'Invalid or unauthorized token')
|
||||
})
|
||||
|
||||
// ============================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user