Skip to content

Commit 0f237cb

Browse files
sh1pt-actions-fleet[bot]ralyodioclaude
authored
feat(rotation): 90-day policy, due runs wait for owner approval (CLI 0.16.0) (#270)
Every automatic secret older than the team's max age (90 days default) is queued as one run in awaiting-approval. An owner/admin approves it on the Rotation page or with `teams rotation-approve`; machine keys can queue but never approve. logicsrc-rotate --queue (*/5 cron) runs approved runs with propagate + health checks + rollback. rotation_auto_approve per team is the paid automatic rotation add-on (off by default). Co-authored-by: Anthony Ettinger <anthony@profullstack.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 87bbc50 commit 0f237cb

11 files changed

Lines changed: 407 additions & 24 deletions

File tree

‎apps/pwa/src/lib/rotation.mjs‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@ import { planRotations, isoTime } from "@logicsrc/cli/dist/rotation-catalog.js";
1111
import { DESKTOP_RECIPES } from "@logicsrc/cli/dist/desktop-recipes.js";
1212

1313
const ROTATE_PREFIX = "__ROTATE__";
14+
const DAY = 24 * 60 * 60 * 1000;
15+
16+
/** Default rotation policy: an automatic secret is due once its value is this many days old. */
17+
export const DEFAULT_MAX_AGE_DAYS = 90;
18+
/** One due run carries at most this many keys (the oldest first); the rest wait for the next. */
19+
export const DUE_RUN_MAX_KEYS = 200;
20+
21+
/** A team's max age in days: its own setting, else the default. */
22+
export function maxAgeDays(team) {
23+
const n = Number(team?.rotation_max_age_days);
24+
return Number.isInteger(n) && n > 0 ? n : DEFAULT_MAX_AGE_DAYS;
25+
}
1426

1527
/** Vault names split the way the CLI does: <project>--<env>. */
1628
export function splitVaultName(name) {
@@ -23,7 +35,7 @@ export function splitVaultName(name) {
2335
* `logicsrc teams rotation`. `vaultFilter(name)` limits it to vaults the caller
2436
* may see (a machine key's scope).
2537
*/
26-
export async function rotationPlan(teamId, { project, env, keys, vaultFilter = () => true } = {}) {
38+
export async function rotationPlan(teamId, { project, env, keys, vaultFilter = () => true, maxAge = DEFAULT_MAX_AGE_DAYS, now = Date.now() } = {}) {
2739
const rows = (await all(
2840
`SELECT v.name AS vault, s.name AS key, s.fingerprint, s.updated_at
2941
FROM credshare_secrets s JOIN credshare_vaults v ON v.id = s.vault_id
@@ -50,6 +62,8 @@ export async function rotationPlan(teamId, { project, env, keys, vaultFilter = (
5062
const spec = p.spec ?? {};
5163
const recipe = spec.method === "desktop" ? DESKTOP_RECIPES[spec.recipe] : null;
5264
const everywhere = byFp.get(p.fingerprint) ?? [];
65+
const rotatedAt = Date.parse(isoTime(p.oldestUpdate));
66+
const ageDays = Number.isFinite(rotatedAt) ? Math.floor((now - rotatedAt) / DAY) : null;
5367
return {
5468
key: p.key,
5569
method: p.method,
@@ -68,6 +82,9 @@ export async function rotationPlan(teamId, { project, env, keys, vaultFilter = (
6882
vaultCount: everywhere.length || p.vaults.length,
6983
alsoIn: everywhere.filter((vk) => !p.vaults.some((v) => vk === `${v}:${p.key}`)),
7084
lastRotated: isoTime(p.oldestUpdate),
85+
ageDays,
86+
// Past the team's max age: the next due run picks it up (automatic keys only).
87+
due: Boolean(p.automatic && !spec.never && ageDays !== null && ageDays >= maxAge),
7188
// The key's own encrypted instructions override the catalog; only the CLI can read them.
7289
hasEntry: p.vaults.some((v) => entries.has(`${v}\u0000${p.key}`))
7390
};
@@ -78,6 +95,8 @@ export async function rotationPlan(teamId, { project, env, keys, vaultFilter = (
7895

7996
const KEY_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
8097
const HOST = /^[A-Za-z0-9._@-]{1,100}$/;
98+
/** A run still open: a due run is not queued again while one of these exists. */
99+
export const OPEN_RUN_STATUSES = ["awaiting-approval", "queued", "running"];
81100
const RESULT_STATUSES = new Set(["rotated", "would-rotate", "todo", "failed", "rolled-back", "skipped", "covered", "needs-login", "busy"]);
82101

83102
export class RotationRunError extends Error {}
@@ -139,6 +158,9 @@ export function runRow(r) {
139158
approve: Boolean(Number(r.approve)),
140159
options: parse(r.options, {}),
141160
status: r.status,
161+
due: Boolean(parse(r.options, {}).due),
162+
approvedBy: r.approved_by_email ?? r.approved_by ?? null,
163+
approvedAt: r.approved_at ? Number(r.approved_at) : null,
142164
requestedBy: r.requested_by_email ?? r.requested_by,
143165
claimedBy: r.claimed_by ?? null,
144166
claimedAt: r.claimed_at ? Number(r.claimed_at) : null,
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Postgres copy of migrations/011_rotation_policy.sql (INTEGER -> bigint). Additive only.
2+
-- 90-day rotation policy: due automatic secrets are queued as one run awaiting
3+
-- owner/admin approval; rotation_auto_approve (paid add-on) skips the click.
4+
ALTER TABLE credshare_teams ADD COLUMN IF NOT EXISTS rotation_max_age_days integer;
5+
ALTER TABLE credshare_teams ADD COLUMN IF NOT EXISTS rotation_auto_approve integer NOT NULL DEFAULT 0;
6+
ALTER TABLE credshare_rotation_runs ADD COLUMN IF NOT EXISTS approved_by text;
7+
ALTER TABLE credshare_rotation_runs ADD COLUMN IF NOT EXISTS approved_at bigint;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- 90-day rotation policy (Anthony, 2026-10-29).
2+
--
3+
-- Every automatic secret older than the team's max age (NULL = 90 days) is
4+
-- queued as one run that waits for an owner or admin to approve it
5+
-- (status 'awaiting-approval'). rotation_auto_approve is the paid add-on:
6+
-- when 1, due runs are queued already approved and rotate without a click.
7+
-- approved_by / approved_at record who said yes. Additive only.
8+
ALTER TABLE credshare_teams ADD COLUMN rotation_max_age_days INTEGER;
9+
ALTER TABLE credshare_teams ADD COLUMN rotation_auto_approve INTEGER NOT NULL DEFAULT 0;
10+
ALTER TABLE credshare_rotation_runs ADD COLUMN approved_by TEXT;
11+
ALTER TABLE credshare_rotation_runs ADD COLUMN approved_at INTEGER;

0 commit comments

Comments
 (0)