Skip to content
Open
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
23 changes: 23 additions & 0 deletions docs/02-User-Guide/ai-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,35 @@ Gemini API pricing (as of 2024):
- Gemini 2.0 Flash: Free tier available
- Monitor token usage in `conversation_analyses.prompt_tokens` and `completion_tokens`

## Read-Only Mode Support

AI Analysis can now work in read-only dashboard mode when properly configured:

### Requirements

- `GEMINI_API_KEY` must be set
- `AI_ANALYSIS_READONLY_ENABLED=true` must be explicitly set
- AI worker must be enabled (`AI_WORKER_ENABLED=true`)

### Configuration Example

```bash
# Enable AI Analysis in read-only mode
GEMINI_API_KEY=your-gemini-api-key
AI_ANALYSIS_READONLY_ENABLED=true
AI_WORKER_ENABLED=true
# DASHBOARD_API_KEY not set (read-only mode)
```

When enabled, users can generate and regenerate AI analyses even without dashboard authentication. The dashboard will show appropriate tooltips explaining when the feature is available.

## Security Notes

1. The Gemini API key is sensitive - never commit it to git
2. Analysis results are stored in your database
3. Rate limiting prevents abuse
4. Audit logging tracks all operations
5. Read-only mode with AI Analysis requires explicit opt-in via `AI_ANALYSIS_READONLY_ENABLED`

## Optional: Disable Feature

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,22 @@ We will **keep read-only mode with clear warnings** (Option 2) for now, but with

This mode should **NEVER** be used in production or any environment accessible from the internet.

### Exception: AI Analysis Feature

As of implementation date, AI Analysis can be enabled in read-only mode with explicit configuration:

- **Requirement**: Both `GEMINI_API_KEY` and `AI_ANALYSIS_READONLY_ENABLED=true` must be set
- **Security Model**: Uses capability-based permissions (`canUseAiAnalysis`)
- **Implementation**: Specific endpoint allowlist for AI Analysis operations only
- **Rationale**: Allows valuable analysis features without full authentication while maintaining security

This exception is carefully controlled:

1. Requires explicit opt-in via feature flag
2. Only specific POST endpoints are allowed (`/api/analyses` and regenerate)
3. All other write operations remain blocked
4. Rate limiting still applies

## Future Recommendations

1. **Phase 1** (Current): Add warnings and documentation
Expand Down
21 changes: 11 additions & 10 deletions docs/06-Reference/environment-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,17 @@ SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL

### AI Analysis Security

| Variable | Description | Default |
| ------------------------------------------------ | ------------------------------------------------ | ------- |
| `AI_ANALYSIS_MAX_RETRIES` | Maximum retry attempts for failed analyses | `2` |
| `AI_ANALYSIS_REQUEST_TIMEOUT_MS` | Timeout for Gemini API requests (ms) | `60000` |
| `AI_ANALYSIS_RATE_LIMIT_CREATION` | Rate limit for analysis creation (per minute) | `15` |
| `AI_ANALYSIS_RATE_LIMIT_RETRIEVAL` | Rate limit for analysis retrieval (per minute) | `100` |
| `AI_ANALYSIS_ENABLE_PII_REDACTION` | Enable PII redaction in conversation content | `true` |
| `AI_ANALYSIS_ENABLE_PROMPT_INJECTION_PROTECTION` | Enable prompt injection protection | `true` |
| `AI_ANALYSIS_ENABLE_OUTPUT_VALIDATION` | Enable output validation for analysis results | `true` |
| `AI_ANALYSIS_ENABLE_AUDIT_LOGGING` | Enable audit logging for all analysis operations | `true` |
| Variable | Description | Default |
| ------------------------------------------------ | ------------------------------------------------------------------------ | ------- |
| `AI_ANALYSIS_MAX_RETRIES` | Maximum retry attempts for failed analyses | `2` |
| `AI_ANALYSIS_REQUEST_TIMEOUT_MS` | Timeout for Gemini API requests (ms) | `60000` |
| `AI_ANALYSIS_RATE_LIMIT_CREATION` | Rate limit for analysis creation (per minute) | `15` |
| `AI_ANALYSIS_RATE_LIMIT_RETRIEVAL` | Rate limit for analysis retrieval (per minute) | `100` |
| `AI_ANALYSIS_ENABLE_PII_REDACTION` | Enable PII redaction in conversation content | `true` |
| `AI_ANALYSIS_ENABLE_PROMPT_INJECTION_PROTECTION` | Enable prompt injection protection | `true` |
| `AI_ANALYSIS_ENABLE_OUTPUT_VALIDATION` | Enable output validation for analysis results | `true` |
| `AI_ANALYSIS_ENABLE_AUDIT_LOGGING` | Enable audit logging for all analysis operations | `true` |
| `AI_ANALYSIS_READONLY_ENABLED` | Enable AI Analysis in read-only dashboard mode (requires GEMINI_API_KEY) | `false` |

Example truncation strategy JSON:

Expand Down
194 changes: 194 additions & 0 deletions services/dashboard/src/__tests__/ai-analysis-readonly.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
import { createDashboardApp } from '../app.js'

describe('AI Analysis in Read-Only Mode', () => {
let app: Awaited<ReturnType<typeof createDashboardApp>>
let originalApiKey: string | undefined
let originalGeminiKey: string | undefined
let originalFeatureFlag: string | undefined

beforeEach(async () => {
// Save original environment variables
originalApiKey = process.env.DASHBOARD_API_KEY
originalGeminiKey = process.env.GEMINI_API_KEY
originalFeatureFlag = process.env.AI_ANALYSIS_READONLY_ENABLED
})

afterEach(() => {
// Restore original environment variables
if (originalApiKey !== undefined) {
process.env.DASHBOARD_API_KEY = originalApiKey
} else {
delete process.env.DASHBOARD_API_KEY
}

if (originalGeminiKey !== undefined) {
process.env.GEMINI_API_KEY = originalGeminiKey
} else {
delete process.env.GEMINI_API_KEY
}

if (originalFeatureFlag !== undefined) {
process.env.AI_ANALYSIS_READONLY_ENABLED = originalFeatureFlag
} else {
delete process.env.AI_ANALYSIS_READONLY_ENABLED
}
})

describe('Configuration Tests', () => {
test('AI Analysis should be disabled in read-only mode without Gemini key', async () => {
// Setup: Read-only mode, no Gemini key
delete process.env.DASHBOARD_API_KEY
delete process.env.GEMINI_API_KEY
delete process.env.AI_ANALYSIS_READONLY_ENABLED

app = await createDashboardApp()

const response = await app.request('/api/analyses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
conversationId: 'test-conv',
branchId: 'test-branch',
}),
})

expect(response.status).toBe(403)
const data = (await response.json()) as any
expect(data.message).toContain('read-only mode')
})

test('AI Analysis should be disabled in read-only mode with only Gemini key', async () => {
// Setup: Read-only mode with Gemini key but no feature flag
delete process.env.DASHBOARD_API_KEY
process.env.GEMINI_API_KEY = 'test-gemini-key'
delete process.env.AI_ANALYSIS_READONLY_ENABLED

app = await createDashboardApp()

const response = await app.request('/api/analyses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
conversationId: 'test-conv',
branchId: 'test-branch',
}),
})

expect(response.status).toBe(403)
const data = (await response.json()) as any
expect(data.message).toContain('read-only mode')
})

test('AI Analysis should be enabled in read-only mode with Gemini key and feature flag', async () => {
// Setup: Read-only mode with both Gemini key and feature flag
delete process.env.DASHBOARD_API_KEY
process.env.GEMINI_API_KEY = 'test-gemini-key'
process.env.AI_ANALYSIS_READONLY_ENABLED = 'true'

app = await createDashboardApp()

const response = await app.request('/api/analyses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
conversationId: 'test-conv',
branchId: 'test-branch',
}),
})

// Should not return 403 (may return other errors like 400 or 500 due to test setup)
expect(response.status).not.toBe(403)
})

test('AI Analysis regenerate endpoint should also be allowed with feature enabled', async () => {
// Setup: Read-only mode with both Gemini key and feature flag
delete process.env.DASHBOARD_API_KEY
process.env.GEMINI_API_KEY = 'test-gemini-key'
process.env.AI_ANALYSIS_READONLY_ENABLED = 'true'

app = await createDashboardApp()

const response = await app.request('/api/analyses/test-conv/test-branch/regenerate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
})

// Should not return 403 (may return other errors like 400 or 500 due to test setup)
expect(response.status).not.toBe(403)
})

test('Other write operations should remain blocked in read-only mode', async () => {
// Setup: Read-only mode with AI Analysis enabled
delete process.env.DASHBOARD_API_KEY
process.env.GEMINI_API_KEY = 'test-gemini-key'
process.env.AI_ANALYSIS_READONLY_ENABLED = 'true'

app = await createDashboardApp()

// Try a different POST endpoint that should still be blocked
const response = await app.request('/api/some-other-endpoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
})

// Should be blocked by read-only protection
expect([403, 404]).toContain(response.status)
})
})

describe('Auth Context Tests', () => {
test('canUseAiAnalysis should be true when authenticated', async () => {
// Setup: Authenticated mode with Gemini key
process.env.DASHBOARD_API_KEY = 'test-api-key'
process.env.GEMINI_API_KEY = 'test-gemini-key'

app = await createDashboardApp()

// This would require accessing the auth context, which is internal
// We can test this indirectly through the UI or API behavior
const response = await app.request('/dashboard', {
headers: {
Cookie: 'dashboard_auth=test-api-key',
},
})

expect(response.status).toBe(200)
// The UI should have AI Analysis enabled
})

test('canUseAiAnalysis should be false in read-only without feature flag', async () => {
// Setup: Read-only mode without feature flag
delete process.env.DASHBOARD_API_KEY
process.env.GEMINI_API_KEY = 'test-gemini-key'
process.env.AI_ANALYSIS_READONLY_ENABLED = 'false'

app = await createDashboardApp()

const response = await app.request('/api/analyses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
conversationId: 'test-conv',
branchId: 'test-branch',
}),
})

expect(response.status).toBe(403)
})
})
})
22 changes: 22 additions & 0 deletions services/dashboard/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,26 @@ export const isReadOnly = () => !process.env.DASHBOARD_API_KEY
*/
export const getDashboardApiKey = () => process.env.DASHBOARD_API_KEY

/**
* Check if Gemini API key is configured and valid
* Note: This is a function to allow dynamic checking in tests
*/
export const hasGeminiKey = () => {
const key = process.env.GEMINI_API_KEY?.trim()
// Ensure key exists and has reasonable length
return !!key && key.length > 10
}

/**
* Check if AI Analysis should be enabled in read-only mode
* Requires both GEMINI_API_KEY and explicit feature flag
* Note: This is a function to allow dynamic checking in tests
*/
export const isAiAnalysisEnabledInReadOnly = () => {
const featureEnabled = process.env.AI_ANALYSIS_READONLY_ENABLED === 'true'
return featureEnabled && hasGeminiKey()
}

// Legacy exports for backward compatibility
export const dashboardApiKey = process.env.DASHBOARD_API_KEY

Expand All @@ -24,4 +44,6 @@ export const dashboardApiKey = process.env.DASHBOARD_API_KEY
export const dashboardConfig = {
isReadOnly: isReadOnly(),
dashboardApiKey,
hasGeminiKey: hasGeminiKey(),
aiAnalysisEnabledInReadOnly: isAiAnalysisEnabledInReadOnly(),
} as const
14 changes: 13 additions & 1 deletion services/dashboard/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { Context, Next, MiddlewareHandler } from 'hono'
import { getCookie } from 'hono/cookie'
import { isReadOnly, getDashboardApiKey } from '../config.js'
import {
isReadOnly,
getDashboardApiKey,
hasGeminiKey,
isAiAnalysisEnabledInReadOnly,
} from '../config.js'

export type AuthContext = {
isAuthenticated: boolean
isReadOnly: boolean
canUseAiAnalysis: boolean
}

/**
Expand All @@ -30,10 +36,14 @@ export const dashboardAuth: MiddlewareHandler<{ Variables: { auth: AuthContext }
const readOnly = isReadOnly()
const apiKey = getDashboardApiKey()

// Determine if AI Analysis can be used
const canUseAiAnalysis = readOnly ? isAiAnalysisEnabledInReadOnly() : true // In authenticated mode, AI Analysis is always available if Gemini key exists

// Set read-only mode in context
c.set('auth', {
isAuthenticated: false,
isReadOnly: readOnly,
canUseAiAnalysis: canUseAiAnalysis && hasGeminiKey(),
})

// If in read-only mode, allow access without authentication
Expand Down Expand Up @@ -61,6 +71,7 @@ export const dashboardAuth: MiddlewareHandler<{ Variables: { auth: AuthContext }
c.set('auth', {
isAuthenticated: true,
isReadOnly: false,
canUseAiAnalysis: hasGeminiKey(),
})
return next()
}
Expand All @@ -71,6 +82,7 @@ export const dashboardAuth: MiddlewareHandler<{ Variables: { auth: AuthContext }
c.set('auth', {
isAuthenticated: true,
isReadOnly: false,
canUseAiAnalysis: hasGeminiKey(),
})
return next()
}
Expand Down
Loading