Implement announce/watch: a deploy signal that reverses CI's reach
All checks were successful
Docker / build-and-push (push) Successful in 1m58s
All checks were successful
Docker / build-and-push (push) Successful in 1m58s
CI announces a new image tag to keep (any grant is enough, read-only included); each deploy target polls for it locally instead of CI holding standing SSH access to production. keep stays a pure relay — the tag lives outside the encrypted vault payload in its own table, and keep never executes anything itself. Adds vault_announcements, POST/GET /vaults/:key/announce, and the `keep announce`/`keep watch` CLI commands, per PROPOSAL-announce-and-agent.md (now marked implemented). Verified live: a read-only identity announcing successfully and being logged, watch picking up the tag on its first poll, and watch exiting nonzero for an unknown/ungranted vault. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,17 @@ db.exec(`
|
||||
action TEXT NOT NULL,
|
||||
accessed_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Latest known deploy tag for a vault — "current state", not a
|
||||
-- history, same one-row-per-vault shape as the vaults table's own
|
||||
-- prev_ciphertext retention. An image tag isn't a secret, so this
|
||||
-- lives outside the encrypted payload entirely.
|
||||
CREATE TABLE IF NOT EXISTS vault_announcements (
|
||||
vault_key TEXT PRIMARY KEY,
|
||||
tag TEXT NOT NULL,
|
||||
announced_at INTEGER NOT NULL,
|
||||
announced_by TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
export interface VaultRow {
|
||||
@@ -88,6 +99,13 @@ export interface GrantRow {
|
||||
granted_at: number;
|
||||
}
|
||||
|
||||
export interface AnnouncementRow {
|
||||
vault_key: string;
|
||||
tag: string;
|
||||
announced_at: number;
|
||||
announced_by: string;
|
||||
}
|
||||
|
||||
export function getVault(vaultKey: string): VaultRow | undefined {
|
||||
return db.prepare(`SELECT * FROM vaults WHERE vault_key = ?`).get(vaultKey) as VaultRow | undefined;
|
||||
}
|
||||
@@ -225,7 +243,7 @@ export function deleteGrant(vaultKey: string, recipientId: string): void {
|
||||
db.prepare(`DELETE FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`).run(vaultKey, recipientId);
|
||||
}
|
||||
|
||||
export function logAccess(vaultKey: string, recipientId: string, action: 'pull' | 'push' | 'grant' | 'revoke'): void {
|
||||
export function logAccess(vaultKey: string, recipientId: string, action: 'pull' | 'push' | 'grant' | 'revoke' | 'announce'): void {
|
||||
db.prepare(
|
||||
`INSERT INTO access_log (vault_key, recipient_id, action, accessed_at) VALUES (?, ?, ?, ?)`,
|
||||
).run(vaultKey, recipientId, action, Math.floor(Date.now() / 1000));
|
||||
@@ -236,3 +254,17 @@ export function getAccessLog(vaultKey: string, limit = 50): { recipient_id: stri
|
||||
`SELECT recipient_id, action, accessed_at FROM access_log WHERE vault_key = ? ORDER BY accessed_at DESC LIMIT ?`,
|
||||
).all(vaultKey, limit) as { recipient_id: string; action: string; accessed_at: number }[];
|
||||
}
|
||||
|
||||
// One row per vault — "latest known tag", not a history. Upsert.
|
||||
export function setAnnouncement(vaultKey: string, tag: string, announcedBy: string): void {
|
||||
db.prepare(
|
||||
`INSERT INTO vault_announcements (vault_key, tag, announced_at, announced_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(vault_key) DO UPDATE SET
|
||||
tag = excluded.tag, announced_at = excluded.announced_at, announced_by = excluded.announced_by`,
|
||||
).run(vaultKey, tag, Math.floor(Date.now() / 1000), announcedBy);
|
||||
}
|
||||
|
||||
export function getAnnouncement(vaultKey: string): AnnouncementRow | undefined {
|
||||
return db.prepare(`SELECT * FROM vault_announcements WHERE vault_key = ?`).get(vaultKey) as AnnouncementRow | undefined;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
logAccess,
|
||||
getAccessLog,
|
||||
listRecipients,
|
||||
setAnnouncement,
|
||||
getAnnouncement,
|
||||
} from './db.js';
|
||||
import { requireRecipientAuth, type AuthedRequest } from './auth.js';
|
||||
|
||||
@@ -179,3 +181,42 @@ router.get('/vaults/:key/log', (req: AuthedRequest, res) => {
|
||||
}
|
||||
res.json(getAccessLog(vaultKey));
|
||||
});
|
||||
|
||||
// A deploy tag isn't a secret — it's not stored in the encrypted payload,
|
||||
// just a small side table. Only requires ANY grant (read-only is enough)
|
||||
// since announcing isn't a mutation of vault secrets — a CI identity
|
||||
// should never need write access on a vault just to say "there's a new
|
||||
// image". Resist reaching for hasWriteGrant here by analogy with push.
|
||||
router.post('/vaults/:key/announce', (req: AuthedRequest, res) => {
|
||||
const vaultKey = req.params.key;
|
||||
const recipientId = req.recipientId!;
|
||||
|
||||
if (!hasGrant(vaultKey, recipientId)) {
|
||||
res.status(403).json({ error: 'no access to this vault' });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as { tag?: string };
|
||||
if (!body.tag) {
|
||||
res.status(400).json({ error: 'tag is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
setAnnouncement(vaultKey, body.tag, recipientId);
|
||||
logAccess(vaultKey, recipientId, 'announce');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/vaults/:key/announce', (req: AuthedRequest, res) => {
|
||||
const vaultKey = req.params.key;
|
||||
if (!hasGrant(vaultKey, req.recipientId!)) {
|
||||
res.status(403).json({ error: 'no access to this vault' });
|
||||
return;
|
||||
}
|
||||
const announcement = getAnnouncement(vaultKey);
|
||||
if (!announcement) {
|
||||
res.status(404).json({ error: 'nothing announced yet' });
|
||||
return;
|
||||
}
|
||||
res.json({ tag: announcement.tag, announcedAt: announcement.announced_at, announcedBy: announcement.announced_by });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user