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:
小墨 2026-04-13 09:51:00 +00:00
parent 8e0f1e3a28
commit bc12a4bb18
2 changed files with 102 additions and 2 deletions

View File

@ -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)
// ============================================

View File

@ -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')
})
// ============================================