Add keep overview command and a docker-compose deploy wrapper
All checks were successful
Docker / build-and-push (push) Successful in 1m57s
All checks were successful
Docker / build-and-push (push) Successful in 1m57s
keep overview gives an admin a cross-vault view of every recipient and grant in one screen, instead of walking vaults one at a time via /vaults/:key/recipients. scripts/deploy.sh pulls a project's env from keep before running docker compose up, for VPS deploys of several docker-compose projects. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -189,6 +189,7 @@ keep recipient add --label "..." --pubkey <hex>
|
|||||||
keep recipient list
|
keep recipient list
|
||||||
keep recipient remove <recipient-id>
|
keep recipient remove <recipient-id>
|
||||||
keep revoke <vault> <recipient-id>
|
keep revoke <vault> <recipient-id>
|
||||||
|
keep overview -- every vault + every grant in one screen (GET /api/admin/overview)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Admin operations need their own authorization story
|
### Admin operations need their own authorization story
|
||||||
|
|||||||
@@ -31,6 +31,33 @@ A deploy identity should almost always be granted **read-only** access
|
|||||||
needs to `pull`, and a read-only grant means a compromised deploy box
|
needs to `pull`, and a read-only grant means a compromised deploy box
|
||||||
can't also rotate the vault or add itself more recipients.
|
can't also rotate the vault or add itself more recipients.
|
||||||
|
|
||||||
|
## Pattern: several docker-compose projects on one or more VPSes
|
||||||
|
|
||||||
|
The common shape once more than one or two projects are on `keep`:
|
||||||
|
each project lives in its own directory on the VPS
|
||||||
|
(`/opt/docker/<project>/docker-compose.yml`), and "deploy" means pulling
|
||||||
|
that project's env and running `docker compose up -d` in its directory.
|
||||||
|
`scripts/deploy.sh` in this repo is a small generic wrapper for exactly
|
||||||
|
that:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy.sh myapp # pulls myapp/production, runs compose in /opt/docker/myapp
|
||||||
|
./deploy.sh myapp staging # pulls myapp/staging instead
|
||||||
|
DEPLOY_ROOT=/srv/apps ./deploy.sh myapp # different project root
|
||||||
|
```
|
||||||
|
|
||||||
|
One-time setup per VPS: `keep identity init` on that machine, register
|
||||||
|
its public key with `keep recipient add`, `keep identity set-id`, then
|
||||||
|
grant it **read-only** access to each project vault it deploys
|
||||||
|
(`keep grant <project>/production <vps-recipient-id> --read-only`). After
|
||||||
|
that, `./deploy.sh <project>` is the whole deploy step — no `.env`
|
||||||
|
hand-copied onto the box first.
|
||||||
|
|
||||||
|
For "which machine can deploy which project," `keep overview` (admin)
|
||||||
|
prints every vault and every recipient's access in one screen — the
|
||||||
|
thing to reach for instead of checking each vault's grants one at a
|
||||||
|
time.
|
||||||
|
|
||||||
## Pattern: docker-compose reading a local `.env`
|
## Pattern: docker-compose reading a local `.env`
|
||||||
|
|
||||||
A common shape — `docker compose up -d` reads `environment:` values from
|
A common shape — `docker compose up -d` reads `environment:` values from
|
||||||
|
|||||||
25
scripts/deploy.sh
Executable file
25
scripts/deploy.sh
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pulls a project's env from keep, then brings its docker compose stack up.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./deploy.sh <project> [env]
|
||||||
|
#
|
||||||
|
# Expects, per project, a directory at $DEPLOY_ROOT/<project>/ containing
|
||||||
|
# a docker-compose.yml. Vault key pulled is "<project>/<env>" (env
|
||||||
|
# defaults to "production"). Requires a keep identity already registered
|
||||||
|
# and granted read access to that vault on this machine
|
||||||
|
# (see INTEGRATION.md).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
project="${1:?usage: deploy.sh <project> [env]}"
|
||||||
|
env="${2:-production}"
|
||||||
|
root="${DEPLOY_ROOT:-/opt/docker}"
|
||||||
|
dir="$root/$project"
|
||||||
|
|
||||||
|
if [ ! -d "$dir" ]; then
|
||||||
|
echo "error: $dir does not exist" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
keep pull "$project/$env" --out "$dir/.env"
|
||||||
|
(cd "$dir" && docker compose up -d)
|
||||||
@@ -18,3 +18,22 @@ export async function recipientRemove(recipientId: string): Promise<void> {
|
|||||||
await expectOk(await adminFetch('DELETE', `/api/admin/recipients/${encodeURIComponent(recipientId)}`));
|
await expectOk(await adminFetch('DELETE', `/api/admin/recipients/${encodeURIComponent(recipientId)}`));
|
||||||
console.log(`recipient removed: ${recipientId} (and all their vault grants)`);
|
console.log(`recipient removed: ${recipientId} (and all their vault grants)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function adminOverview(): Promise<void> {
|
||||||
|
const data = await expectOk(await adminFetch('GET', '/api/admin/overview'));
|
||||||
|
|
||||||
|
console.log('recipients:');
|
||||||
|
if (!data.recipients.length) console.log(' none yet.');
|
||||||
|
for (const r of data.recipients) {
|
||||||
|
console.log(` ${r.recipientId} ${r.label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nvaults:');
|
||||||
|
if (!data.vaults.length) console.log(' none yet.');
|
||||||
|
for (const v of data.vaults) {
|
||||||
|
console.log(` ${v.vaultKey}`);
|
||||||
|
for (const g of v.grants) {
|
||||||
|
console.log(` ${g.canWrite ? 'rw' : 'r-'} ${g.recipientId} ${g.label ?? '(unknown recipient)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { identityInit, identityShow, identitySetId } from './commands/identity.js';
|
import { identityInit, identityShow, identitySetId } from './commands/identity.js';
|
||||||
import { recipientAdd, recipientList, recipientRemove } from './commands/recipient.js';
|
import { recipientAdd, recipientList, recipientRemove, adminOverview } from './commands/recipient.js';
|
||||||
import { vaultPush, vaultPull, vaultGrant, vaultRevoke, vaultLog } from './commands/vault.js';
|
import { vaultPush, vaultPull, vaultGrant, vaultRevoke, vaultLog } from './commands/vault.js';
|
||||||
|
|
||||||
function flag(args: string[], name: string, fallback?: string): string | undefined {
|
function flag(args: string[], name: string, fallback?: string): string | undefined {
|
||||||
@@ -28,6 +28,8 @@ async function main(): Promise<void> {
|
|||||||
if (sub === 'remove') return await recipientRemove(rest[0]);
|
if (sub === 'remove') return await recipientRemove(rest[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cmd === 'overview') return await adminOverview();
|
||||||
|
|
||||||
if (cmd === 'push') return await vaultPush(sub, flag(rest, 'file', '.env')!, boolFlag(rest, 'purge'));
|
if (cmd === 'push') return await vaultPush(sub, flag(rest, 'file', '.env')!, boolFlag(rest, 'purge'));
|
||||||
if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out'), boolFlag(rest, 'previous'));
|
if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out'), boolFlag(rest, 'previous'));
|
||||||
if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only'));
|
if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only'));
|
||||||
@@ -58,6 +60,7 @@ function printUsage(): void {
|
|||||||
keep recipient add --label <label> --pubkey <hex> (admin)
|
keep recipient add --label <label> --pubkey <hex> (admin)
|
||||||
keep recipient list (admin)
|
keep recipient list (admin)
|
||||||
keep recipient remove <recipient-id> (admin)
|
keep recipient remove <recipient-id> (admin)
|
||||||
|
keep overview (admin — every vault, every grant, one screen)
|
||||||
|
|
||||||
Env: KEEP_SERVER_URL (default http://localhost:3050), KEEP_ADMIN_PASSWORD (for admin commands)`);
|
Env: KEEP_SERVER_URL (default http://localhost:3050), KEEP_ADMIN_PASSWORD (for admin commands)`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { listRecipients, createRecipient, deleteRecipient, deleteGrant, getAccessLog, logAccess } from './db.js';
|
import { listRecipients, createRecipient, deleteRecipient, deleteGrant, getAccessLog, logAccess, listVaults, listGrantsForVault } from './db.js';
|
||||||
import { requireAdminAuth } from './auth.js';
|
import { requireAdminAuth } from './auth.js';
|
||||||
|
|
||||||
export const router = Router();
|
export const router = Router();
|
||||||
@@ -45,3 +45,26 @@ router.delete('/vaults/:key/grants/:recipientId', (req, res) => {
|
|||||||
router.get('/vaults/:key/log', (req, res) => {
|
router.get('/vaults/:key/log', (req, res) => {
|
||||||
res.json(getAccessLog(req.params.key));
|
res.json(getAccessLog(req.params.key));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cross-vault view — "which things have access to what" in one call,
|
||||||
|
// instead of walking every vault by hand with /vaults/:key/recipients
|
||||||
|
// (which is itself grant-gated per vault, and only shows one vault at a
|
||||||
|
// time). Admin-only since it reveals every vault's full grant list at
|
||||||
|
// once, not just the ones the caller happens to have a grant on.
|
||||||
|
router.get('/overview', (_req, res) => {
|
||||||
|
const byId = new Map(listRecipients().map(r => [r.recipient_id, r]));
|
||||||
|
const vaults = listVaults().map(v => ({
|
||||||
|
vaultKey: v.vault_key,
|
||||||
|
updatedAt: v.updated_at,
|
||||||
|
updatedBy: v.updated_by,
|
||||||
|
grants: listGrantsForVault(v.vault_key).map(g => ({
|
||||||
|
recipientId: g.recipient_id,
|
||||||
|
label: byId.get(g.recipient_id)?.label ?? null,
|
||||||
|
canWrite: g.can_write === 1,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
res.json({
|
||||||
|
recipients: listRecipients().map(r => ({ recipientId: r.recipient_id, label: r.label, publicKey: r.public_key, createdAt: r.created_at })),
|
||||||
|
vaults,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -206,6 +206,12 @@ export function listGrantsForVault(vaultKey: string): GrantRow[] {
|
|||||||
return db.prepare(`SELECT * FROM vault_grants WHERE vault_key = ?`).all(vaultKey) as GrantRow[];
|
return db.prepare(`SELECT * FROM vault_grants WHERE vault_key = ?`).all(vaultKey) as GrantRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listVaults(): { vault_key: string; updated_at: number; updated_by: string }[] {
|
||||||
|
return db.prepare(`SELECT vault_key, updated_at, updated_by FROM vaults ORDER BY vault_key`).all() as {
|
||||||
|
vault_key: string; updated_at: number; updated_by: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
export function setGrant(vaultKey: string, recipientId: string, wrappedKeyB64: string, canWrite: boolean): void {
|
export function setGrant(vaultKey: string, recipientId: string, wrappedKeyB64: string, canWrite: boolean): void {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO vault_grants (vault_key, recipient_id, wrapped_key, can_write, granted_at)
|
`INSERT INTO vault_grants (vault_key, recipient_id, wrapped_key, can_write, granted_at)
|
||||||
|
|||||||
Reference in New Issue
Block a user