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
7 changes: 4 additions & 3 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions .github/workflows/terraform-plan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ on:
branches: [main]
paths:
- 'infrastructure/terraform/**'
schedule:
# Run daily at 6 AM UTC for drift detection
- cron: '0 6 * * *'
# schedule disabled — burns Actions minutes
# schedule:
# # Run daily at 6 AM UTC for drift detection
# - cron: '0 6 * * *'
workflow_dispatch:
inputs:
environment:
Expand Down
203 changes: 203 additions & 0 deletions 000-docs/065-AT-RNBK-secrets-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# Secrets Management Runbook

**Document ID:** 065-AT-RNBK-secrets-management
**Category:** AT (Artifact) - RNBK (Runbook)
**Status:** Active
**Last Updated:** 2026-02-03

---

## Overview

IntentVision uses Google Cloud Secret Manager for all sensitive credentials. Secrets are created via Terraform and values are set manually or via CI.

## Naming Convention

```
{environment}-{service}-{key}
```

| Environment | Examples |
|-------------|----------|
| `staging` | `staging-turso-url`, `staging-nixtla-api-key` |
| `production` | `production-turso-url`, `production-nixtla-api-key` |

## Secret Inventory

| Secret Name | Purpose | Required | Rotation |
|-------------|---------|----------|----------|
| `{env}-turso-url` | Turso database connection URL | Yes | On compromise |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The rotation policy for {env}-turso-url is listed as 'On compromise', which could be confusing. Typically, the URL itself is not a secret, but the associated auth token is. The example on line 57 (libsql://your-db.turso.io) also suggests the URL is not sensitive.

If the URL is not a secret, a rotation policy of 'On infrastructure change' would be clearer. This avoids ambiguity for the operator.

Suggested change
| `{env}-turso-url` | Turso database connection URL | Yes | On compromise |
| `{env}-turso-url` | Turso database connection URL | Yes | On infrastructure change |

| `{env}-turso-token` | Turso authentication token | Yes | 90 days |
| `{env}-nixtla-api-key` | Nixtla TimeGPT API key | No | On compromise |
| `{env}-resend-api-key` | Resend email service API key | No | On compromise |

## Creating Secrets (Terraform)

Secrets are created empty by Terraform:

```bash
cd infrastructure/terraform

# Staging
terraform apply -var-file=environments/staging/terraform.tfvars

# Production
terraform apply -var-file=environments/production/terraform.tfvars
```

## Setting Secret Values

### Via gcloud CLI

```bash
# Set a new secret value
echo -n "your-secret-value" | gcloud secrets versions add {secret-name} --data-file=-

# Example: Set production Turso URL
echo -n "libsql://your-db.turso.io" | gcloud secrets versions add production-turso-url --data-file=-

# Example: Set production Turso token
echo -n "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9..." | gcloud secrets versions add production-turso-token --data-file=-
```

### Via Console

1. Go to [Secret Manager Console](https://console.cloud.google.com/security/secret-manager?project=intentvision)
2. Click on the secret name
3. Click "New Version"
4. Enter the secret value
5. Click "Add New Version"

## Viewing Secrets

```bash
# List all secrets
gcloud secrets list --filter="labels.app=intentvision"

# List versions of a secret
gcloud secrets versions list {secret-name}

# Access latest version (requires secretAccessor role)
gcloud secrets versions access latest --secret={secret-name}
```

## Rotation Procedure

### 1. Generate New Credential

Obtain new credential from the service provider:
- **Turso:** Dashboard > Database > Generate Token
- **Nixtla:** Dashboard > API Keys > Create
- **Resend:** Dashboard > API Keys > Create

### 2. Add New Version

```bash
echo -n "new-credential-value" | gcloud secrets versions add {secret-name} --data-file=-
```

### 3. Verify Application

```bash
# Trigger a new Cloud Run revision to pick up the new secret
gcloud run services update intentvision-api-{env} \
--region=us-central1 \
--update-env-vars=ROTATION_TRIGGER=$(date +%s)

# Verify health
# Staging: https://stg.intentvision.intent-solutions.io/health
# Production: https://api.intentvision.io/health
curl https://{your-environment-url}/health
```

### 4. Disable Old Version (After Verification)

```bash
# List versions
gcloud secrets versions list {secret-name}

# Disable old version (keep for rollback window)
gcloud secrets versions disable {version-id} --secret={secret-name}

# Destroy old version (after 7 days)
gcloud secrets versions destroy {version-id} --secret={secret-name}
```

## Emergency Rotation

If a secret is compromised:

```bash
# 1. Immediately rotate at source (revoke old, create new)
# 2. Add new version
echo -n "new-value" | gcloud secrets versions add {secret-name} --data-file=-

# 3. Force redeploy (replace {env} with staging or production)
gcloud run services update intentvision-api-{env} \
--region=us-central1 \
--update-env-vars=EMERGENCY_ROTATION=$(date +%s)

# 4. Destroy compromised version (find ID via: gcloud secrets versions list {secret-name})
gcloud secrets versions destroy {version-id} --secret={secret-name}

# 5. Audit access logs
gcloud logging read 'resource.type="secretmanager.googleapis.com/Secret"' --limit=100
Comment on lines +143 to +144
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The gcloud logging read command for auditing is a good inclusion. However, in a high-stress emergency situation, a more targeted query would be significantly more effective. The current query retrieves logs for all secrets. Filtering by the specific compromised secret will reduce noise and help the operator focus on relevant events more quickly.

Suggested change
# 5. Audit access logs
gcloud logging read 'resource.type="secretmanager.googleapis.com/Secret"' --limit=100
# 5. Audit access logs for the specific secret
gcloud logging read 'resource.type="secretmanager.googleapis.com/Secret" AND protoPayload.resourceName="projects/intentvision/secrets/{secret-name}"' --limit=100

```

## Rotation Schedule

| Secret Type | Frequency | Next Rotation |
|-------------|-----------|---------------|
| Turso tokens | 90 days | Track in calendar |
| API keys | On compromise | N/A |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better security hygiene, it's recommended to rotate API keys periodically, not just upon compromise. A silent compromise could go undetected, and regular rotation limits the exposure window. A policy of rotating keys annually is a common best practice. Consider updating the rotation policy for API keys to include periodic rotation.

Suggested change
| API keys | On compromise | N/A |
| API keys | Yearly or on compromise | Track in calendar |


## Access Control

Secrets are accessible only to:
- Cloud Run service account (`intentvision-api-{env}@intentvision.iam.gserviceaccount.com`)
- Project owners (for management)

IAM is granted per-secret, not project-wide (least privilege).

## Troubleshooting

### Secret Not Found

```bash
# Verify secret exists
gcloud secrets describe {secret-name}

# Check IAM bindings
gcloud secrets get-iam-policy {secret-name}
```

### Access Denied

```bash
# Verify service account has access
gcloud secrets get-iam-policy {secret-name} \
--format="table(bindings.role,bindings.members)"

# Grant access if missing (via Terraform preferred)
gcloud secrets add-iam-policy-binding {secret-name} \
--member="serviceAccount:{sa-email}" \
--role="roles/secretmanager.secretAccessor"
```

### Application Not Picking Up New Secret

Cloud Run caches secrets. Force a new revision:

```bash
gcloud run services update intentvision-api-{env} \
--region=us-central1 \
--update-env-vars=SECRET_REFRESH=$(date +%s)
```

---

## References

- [Secret Manager Documentation](https://cloud.google.com/secret-manager/docs)
- [Terraform secrets.tf](../infrastructure/terraform/secrets.tf)
- [Deploy Runbook](./051-AT-RNBK-intentvision-deploy-rollback.md)
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ RUN npm ci --include=dev

# Copy source code
COPY packages/ ./packages/
COPY db/ ./db/
COPY tsconfig*.json ./

# Build all packages in dependency order
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"db:migrate": "npx tsx db/migrate.ts run",
"db:status": "npx tsx db/migrate.ts status",
"arv": "./scripts/ci/arv-check.sh",
"typecheck": "tsc --noEmit"
"typecheck": "tsc -p packages/contracts --noEmit && tsc -p packages/pipeline --noEmit && tsc -p packages/sdk --noEmit"
},
"repository": {
"type": "git",
Expand Down
12 changes: 6 additions & 6 deletions packages/api/src/agent/a2a-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export class A2AGatewayClient {
*/
async health(): Promise<GatewayHealth> {
const response = await this.fetch('/health');
return response as GatewayHealth;
return response as unknown as GatewayHealth;
}

// ===========================================================================
Expand All @@ -107,15 +107,15 @@ export class A2AGatewayClient {
*/
async listAgents(): Promise<string[]> {
const response = await this.fetch('/agents');
return response as string[];
return response as unknown as string[];
}

/**
* Get agent card for A2A protocol discovery
*/
async getAgentCard(agentName: string): Promise<AgentCard> {
const response = await this.fetch(`/agents/${agentName}/.well-known/agent-card.json`);
return response as AgentCard;
return response as unknown as AgentCard;
}

// ===========================================================================
Expand All @@ -130,7 +130,7 @@ export class A2AGatewayClient {
method: 'POST',
body: JSON.stringify(request),
});
return response as TaskStatus;
return response as unknown as TaskStatus;
}

// ===========================================================================
Expand All @@ -148,7 +148,7 @@ export class A2AGatewayClient {
method: 'POST',
body: JSON.stringify(request),
});
return response as ChatResponse;
return response as unknown as ChatResponse;
}

// ===========================================================================
Expand Down Expand Up @@ -243,7 +243,7 @@ export class A2AGatewayClient {
);
}

return await response.json();
return await response.json() as Record<string, unknown>;
} catch (error) {
clearTimeout(timeoutId);

Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/tests/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* Tests response structure without requiring external dependencies.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { ServerResponse } from 'http';
import {
handleBasicHealth,
Expand Down
3 changes: 3 additions & 0 deletions packages/contracts/src/metrics-spine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ export interface TimeSeries {

/** Detected resolution (e.g., "1m", "5m", "1h") */
resolution?: string;

/** Aggregation method (e.g., "avg", "sum", "max") */
aggregation?: string;
};
}

Expand Down
3 changes: 2 additions & 1 deletion packages/contracts/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"composite": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests", "fixtures"]
Expand Down
6 changes: 3 additions & 3 deletions packages/operator/src/api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ export function createDefaultRouter(): ApiRouter {

// Create API key (admin scope required)
router.route('POST', '/api/v1/keys', async (req, ctx) => {
const body = req.body as { name?: string; scopes?: string[]; expires_in_days?: number };
const body = req.body as { name?: string; roles?: string[]; expires_in_days?: number };
if (!body?.name) {
return {
status: 400,
Expand All @@ -197,7 +197,7 @@ export function createDefaultRouter(): ApiRouter {
const { key, rawKey } = await manager.createKey({
orgId: ctx.orgId,
name: body.name,
scopes: body.scopes,
roles: body.roles,
expiresInDays: body.expires_in_days,
});

Expand All @@ -207,7 +207,7 @@ export function createDefaultRouter(): ApiRouter {
key_id: key.keyId,
raw_key: rawKey,
name: key.name,
scopes: key.scopes,
roles: key.roles,
expires_at: key.expiresAt,
message: 'Store this key securely - it will not be shown again',
},
Expand Down
2 changes: 1 addition & 1 deletion packages/operator/src/auth/api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export class ApiKeyManager {
args: [orgId],
});

return result.rows.map((row) => ({
return result.rows.map((row: Record<string, unknown>) => ({
keyId: row.key_id as string,
keyHash: row.key_hash as string,
orgId: row.org_id as string,
Expand Down
2 changes: 1 addition & 1 deletion packages/operator/src/auth/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ export class ApiKeyManager {
args: [orgId],
});

return result.rows.map((row) => ({
return result.rows.map((row: Record<string, unknown>) => ({
keyId: row.key_id as string,
keyHash: row.key_hash as string,
orgId: row.org_id as string,
Expand Down
2 changes: 2 additions & 0 deletions packages/operator/src/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export interface AuthResult {
export interface AuthMiddlewareConfig {
/** Required permissions for this endpoint */
requiredPermissions?: Permission[];
/** Required scopes for this endpoint */
requiredScopes?: string[];
Comment on lines +50 to +51
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

requiredScopes is added but never enforced.

The requiredScopes field is accepted in AuthMiddlewareConfig, and router.ts (lines 87-92) passes route.scopes to authenticateRequest as requiredScopes. However, authenticateRequest never reads or validates this field—only requiredPermissions is checked (lines 231-241, 265-275).

This creates a security gap: routes configured with scopes restrictions appear protected but are actually accessible by any authenticated user. Either implement scope checking or remove the field to avoid false security assumptions.

🔒 Proposed fix to enforce scope checking

Add scope validation after the permission checks (e.g., after line 241 for JWT and after line 275 for API key):

+      // Check scopes if required
+      if (config.requiredScopes && config.requiredScopes.length > 0) {
+        const userRoles = jwtResult.context.roles;
+        for (const scope of config.requiredScopes) {
+          if (!userRoles.includes(scope) && !userRoles.includes('*')) {
+            return {
+              authenticated: false,
+              error: `Missing required scope: ${scope}`,
+              statusCode: 403,
+            };
+          }
+        }
+      }
+
       return {
         authenticated: true,
         context: jwtResult.context,
       };

Note: Consider whether "scopes" and "roles" should be unified terminology across the codebase.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/operator/src/auth/middleware.ts` around lines 50 - 51,
AuthMiddlewareConfig added requiredScopes but authenticateRequest never enforces
them—update authenticateRequest to validate requiredScopes (the value passed
from router.ts as route.scopes) after the existing permission checks: for JWT
flows (after the requiredPermissions block that checks user.permissions) verify
the token/user has all requiredScopes and return 403 when any are missing;
likewise for API key flows (after the apiKey permission checks) validate the
key's scopes/claims against requiredScopes and reject if absent. Use the
existing error handling/response pattern and reference
AuthMiddlewareConfig.requiredScopes, authenticateRequest, requiredPermissions,
and the token/apiKey scope/claims fields when locating and implementing the
checks; consider aligning naming (scopes vs roles) consistently across the
codebase.

/** Allow unauthenticated requests */
allowAnonymous?: boolean;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/operator/src/tenant/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/

import { v4 as uuidv4 } from 'uuid';
import type { ApiKey } from '../auth/api-key.js';
import type { ApiKey } from '../auth/api-keys.js';

// =============================================================================
// Types
Expand Down
4 changes: 2 additions & 2 deletions packages/operator/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"rootDir": "../..",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"include": ["src/**/*", "../../db/**/*"],
Comment on lines +10 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how db paths are imported in operator/src
rg -n "from.*['\"].*db/" packages/operator/src/ --type ts -C2

Repository: intent-solutions-io/intent-vision

Length of output: 940


🏁 Script executed:

#!/bin/bash
# Also check relative imports to db
rg -n "from.*['\"].*\.\./.*db" packages/operator/src/ --type ts -C2

Repository: intent-solutions-io/intent-vision

Length of output: 940


🏁 Script executed:

#!/bin/bash
# Get full tsconfig.json content to understand the context
cat -n packages/operator/tsconfig.json

Repository: intent-solutions-io/intent-vision

Length of output: 566


🏁 Script executed:

#!/bin/bash
# Check if other packages use similar patterns
find . -name "tsconfig.json" -path "*/packages/*/tsconfig.json" -exec echo "=== {} ===" \; -exec cat {} \;

Repository: intent-solutions-io/intent-vision

Length of output: 4452


Fix rootDir configuration to prevent nested output directory structure.

Setting rootDir to "../.." causes TypeScript to emit compiled files with a nested structure. Source files like packages/operator/src/auth/api-keys.ts will compile to dist/packages/operator/src/auth/api-keys.ts instead of dist/src/auth/api-keys.ts. Combined with the include of "../../db/**/*", the output at dist/db/config.js won't resolve with relative imports expecting ../../../../db/config.js.

Change rootDir to "./src" to match the standard pattern used across other packages in this repository, or adjust the include paths accordingly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/operator/tsconfig.json` around lines 10 - 14, The tsconfig.json's
"rootDir" is set to "../.." which causes emitted files to nest under
dist/packages/operator/...; update the "rootDir" key to "./src" and keep the
"include" to reference only source files (e.g., "include": ["src/**/*"] or
adjust the "../../db/**/*" entry to match the new root layout) so compiled
output is emitted as dist/src/... and relative imports to db resolve correctly.

"exclude": ["node_modules", "dist", "tests"]
}
Loading
Loading