Add secrets documentation and inline env migration script
Document secret storage in DATABASE.md and DEVELOPING.md. Update SPEC-implementation with company_secrets schema and indexes. Add migrate-inline-env-secrets script for converting existing plain env values to managed secrets (dry-run by default, --apply to commit). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -120,3 +120,39 @@ The database mode is controlled by `DATABASE_URL`:
|
|||||||
| `postgres://...supabase.com...` | Hosted Supabase |
|
| `postgres://...supabase.com...` | Hosted Supabase |
|
||||||
|
|
||||||
Your Drizzle schema (`packages/db/src/schema/`) stays the same regardless of mode.
|
Your Drizzle schema (`packages/db/src/schema/`) stays the same regardless of mode.
|
||||||
|
|
||||||
|
## Secret storage
|
||||||
|
|
||||||
|
Paperclip stores secret metadata and versions in:
|
||||||
|
|
||||||
|
- `company_secrets`
|
||||||
|
- `company_secret_versions`
|
||||||
|
|
||||||
|
For local/default installs, the active provider is `local_encrypted`:
|
||||||
|
|
||||||
|
- Secret material is encrypted at rest with a local master key.
|
||||||
|
- Default key file: `./data/secrets/master.key` (auto-created if missing).
|
||||||
|
- CLI config location: `.paperclip/config.json` under `secrets.localEncrypted.keyFilePath`.
|
||||||
|
|
||||||
|
Optional overrides:
|
||||||
|
|
||||||
|
- `PAPERCLIP_SECRETS_MASTER_KEY` (32-byte key as base64, hex, or raw 32-char string)
|
||||||
|
- `PAPERCLIP_SECRETS_MASTER_KEY_FILE` (custom key file path)
|
||||||
|
|
||||||
|
Strict mode to block new inline sensitive env values:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PAPERCLIP_SECRETS_STRICT_MODE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
You can set strict mode and provider defaults via:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm paperclip configure --section secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
Inline secret migration command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm secrets:migrate-inline-env --apply
|
||||||
|
```
|
||||||
|
|||||||
@@ -56,3 +56,32 @@ pnpm dev
|
|||||||
## Optional: Use External Postgres
|
## Optional: Use External Postgres
|
||||||
|
|
||||||
If you set `DATABASE_URL`, the server will use that instead of embedded PostgreSQL.
|
If you set `DATABASE_URL`, the server will use that instead of embedded PostgreSQL.
|
||||||
|
|
||||||
|
## Secrets in Dev
|
||||||
|
|
||||||
|
Agent env vars now support secret references. By default, secret values are stored with local encryption and only secret refs are persisted in agent config.
|
||||||
|
|
||||||
|
- Default local key path: `./data/secrets/master.key`
|
||||||
|
- Override key material directly: `PAPERCLIP_SECRETS_MASTER_KEY`
|
||||||
|
- Override key file path: `PAPERCLIP_SECRETS_MASTER_KEY_FILE`
|
||||||
|
|
||||||
|
Strict mode (recommended outside local trusted machines):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PAPERCLIP_SECRETS_STRICT_MODE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
When strict mode is enabled, sensitive env keys (for example `*_API_KEY`, `*_TOKEN`, `*_SECRET`) must use secret references instead of inline plain values.
|
||||||
|
|
||||||
|
CLI configuration support:
|
||||||
|
|
||||||
|
- `pnpm paperclip onboard` writes a default `secrets` config section (`local_encrypted`, strict mode off, key file path set) and creates a local key file when needed.
|
||||||
|
- `pnpm paperclip configure --section secrets` lets you update provider/strict mode/key path and creates the local key file when needed.
|
||||||
|
- `pnpm paperclip doctor` validates secrets adapter configuration and can create a missing local key file with `--repair`.
|
||||||
|
|
||||||
|
Migration helper for existing inline env secrets:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm secrets:migrate-inline-env # dry run
|
||||||
|
pnpm secrets:migrate-inline-env --apply # apply migration
|
||||||
|
```
|
||||||
|
|||||||
@@ -275,7 +275,21 @@ Invariant: each event must attach to agent and company; rollups are aggregation,
|
|||||||
- `details` jsonb null
|
- `details` jsonb null
|
||||||
- `created_at` timestamptz not null default now()
|
- `created_at` timestamptz not null default now()
|
||||||
|
|
||||||
## 7.12 Required Indexes
|
## 7.12 `company_secrets` + `company_secret_versions`
|
||||||
|
|
||||||
|
- Secret values are not stored inline in `agents.adapter_config.env`.
|
||||||
|
- Agent env entries should use secret refs for sensitive values.
|
||||||
|
- `company_secrets` tracks identity/provider metadata per company.
|
||||||
|
- `company_secret_versions` stores encrypted/reference material per version.
|
||||||
|
- Default provider in local deployments: `local_encrypted`.
|
||||||
|
|
||||||
|
Operational policy:
|
||||||
|
|
||||||
|
- Config read APIs redact sensitive plain values.
|
||||||
|
- Activity and approval payloads must not persist raw sensitive values.
|
||||||
|
- Config revisions may include redacted placeholders; such revisions are non-restorable for redacted fields.
|
||||||
|
|
||||||
|
## 7.13 Required Indexes
|
||||||
|
|
||||||
- `agents(company_id, status)`
|
- `agents(company_id, status)`
|
||||||
- `agents(company_id, reports_to)`
|
- `agents(company_id, reports_to)`
|
||||||
@@ -288,6 +302,8 @@ Invariant: each event must attach to agent and company; rollups are aggregation,
|
|||||||
- `heartbeat_runs(company_id, agent_id, started_at desc)`
|
- `heartbeat_runs(company_id, agent_id, started_at desc)`
|
||||||
- `approvals(company_id, status, type)`
|
- `approvals(company_id, status, type)`
|
||||||
- `activity_log(company_id, created_at desc)`
|
- `activity_log(company_id, created_at desc)`
|
||||||
|
- `company_secrets(company_id, name)` unique
|
||||||
|
- `company_secret_versions(secret_id, version)` unique
|
||||||
|
|
||||||
## 8. State Machines
|
## 8. State Machines
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"db:generate": "pnpm --filter @paperclip/db generate",
|
"db:generate": "pnpm --filter @paperclip/db generate",
|
||||||
"db:migrate": "pnpm --filter @paperclip/db migrate",
|
"db:migrate": "pnpm --filter @paperclip/db migrate",
|
||||||
|
"secrets:migrate-inline-env": "tsx scripts/migrate-inline-env-secrets.ts",
|
||||||
"db:backup": "./scripts/backup-db.sh",
|
"db:backup": "./scripts/backup-db.sh",
|
||||||
"paperclip": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts"
|
"paperclip": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
125
scripts/migrate-inline-env-secrets.ts
Normal file
125
scripts/migrate-inline-env-secrets.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { agents, createDb } from "@paperclip/db";
|
||||||
|
import { secretService } from "../server/src/services/secrets.js";
|
||||||
|
|
||||||
|
const SENSITIVE_ENV_KEY_RE =
|
||||||
|
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||||
|
|
||||||
|
type EnvBinding =
|
||||||
|
| string
|
||||||
|
| { type: "plain"; value: string }
|
||||||
|
| { type: "secret_ref"; secretId: string; version?: number | "latest" };
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPlainValue(binding: unknown): string | null {
|
||||||
|
if (typeof binding === "string") return binding;
|
||||||
|
if (typeof binding !== "object" || binding === null || Array.isArray(binding)) return null;
|
||||||
|
const rec = binding as Record<string, unknown>;
|
||||||
|
if (rec.type === "plain" && typeof rec.value === "string") return rec.value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function secretName(agentId: string, key: string) {
|
||||||
|
return `agent_${agentId.slice(0, 8)}_${key.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const dbUrl = process.env.DATABASE_URL;
|
||||||
|
if (!dbUrl) {
|
||||||
|
console.error("DATABASE_URL is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apply = process.argv.includes("--apply");
|
||||||
|
const db = createDb(dbUrl);
|
||||||
|
const secrets = secretService(db);
|
||||||
|
|
||||||
|
const allAgents = await db.select().from(agents);
|
||||||
|
let changedAgents = 0;
|
||||||
|
let createdSecrets = 0;
|
||||||
|
let rotatedSecrets = 0;
|
||||||
|
|
||||||
|
for (const agent of allAgents) {
|
||||||
|
const adapterConfig = asRecord(agent.adapterConfig);
|
||||||
|
if (!adapterConfig) continue;
|
||||||
|
const env = asRecord(adapterConfig.env);
|
||||||
|
if (!env) continue;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const nextEnv: Record<string, EnvBinding> = { ...(env as Record<string, EnvBinding>) };
|
||||||
|
|
||||||
|
for (const [key, rawBinding] of Object.entries(env)) {
|
||||||
|
if (!SENSITIVE_ENV_KEY_RE.test(key)) continue;
|
||||||
|
const plain = toPlainValue(rawBinding);
|
||||||
|
if (plain === null) continue;
|
||||||
|
if (plain.trim().length === 0) continue;
|
||||||
|
|
||||||
|
const name = secretName(agent.id, key);
|
||||||
|
if (apply) {
|
||||||
|
const existing = await secrets.getByName(agent.companyId, name);
|
||||||
|
if (existing) {
|
||||||
|
await secrets.rotate(
|
||||||
|
existing.id,
|
||||||
|
{ value: plain },
|
||||||
|
{ userId: "migration", agentId: null },
|
||||||
|
);
|
||||||
|
rotatedSecrets += 1;
|
||||||
|
nextEnv[key] = { type: "secret_ref", secretId: existing.id, version: "latest" };
|
||||||
|
} else {
|
||||||
|
const created = await secrets.create(
|
||||||
|
agent.companyId,
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
provider: "local_encrypted",
|
||||||
|
value: plain,
|
||||||
|
description: `Migrated from agent ${agent.id} env ${key}`,
|
||||||
|
},
|
||||||
|
{ userId: "migration", agentId: null },
|
||||||
|
);
|
||||||
|
createdSecrets += 1;
|
||||||
|
nextEnv[key] = { type: "secret_ref", secretId: created.id, version: "latest" };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextEnv[key] = {
|
||||||
|
type: "secret_ref",
|
||||||
|
secretId: `<would-create:${name}>`,
|
||||||
|
version: "latest",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) continue;
|
||||||
|
changedAgents += 1;
|
||||||
|
|
||||||
|
if (apply) {
|
||||||
|
await db
|
||||||
|
.update(agents)
|
||||||
|
.set({
|
||||||
|
adapterConfig: {
|
||||||
|
...adapterConfig,
|
||||||
|
env: nextEnv,
|
||||||
|
},
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(agents.id, agent.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apply) {
|
||||||
|
console.log(`Dry run: ${changedAgents} agents would be updated`);
|
||||||
|
console.log("Re-run with --apply to persist changes");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Updated ${changedAgents} agents, created ${createdSecrets} secrets, rotated ${rotatedSecrets} secrets`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
Reference in New Issue
Block a user