Skip to content

Commit 6663cda

Browse files
bshandleyclaude
andcommitted
feat(0.14.0): rate limits + approval-gated leases
Two related capabilities in one release. Rate limits - AppRoles gain three optional caps (per-minute/hour/day) settable via the UI or POST/PUT /v1/auth/approle. Per-secret minute caps come from metadata.rate_limit_per_minute. Both apply independently; the most restrictive wins. - Fixed-window counters keyed on Math.floor(now/window_ms), in-memory. Hard ceilings 10K/min, 100K/hour, 1M/day. - 429 with Retry-After when hit. Root tokens and user JWTs exempt. - New audit actions: proxy.blocked.rate_limit. Approval-gated leases (unified model) - metadata.requires_approval=true gates a secret. Agents must call gatehouse_request_access(path, ttl, justification) and wait for a human approval before gatehouse_proxy or gatehouse_lease succeeds. - The approved lease IS the access window. Revoke once, atomic kill. No second checkout lease; each value-reveal audits as lease.access against the approval lease_id. - Status drives lifecycle: pending -> approved/denied/expired. revoked boolean stays orthogonal. - Auto-approve via metadata.auto_approve_from_ip (CIDR list) plus optional auto_approve_ttl_seconds. - Webhook on lease.request_created (GATEHOUSE_APPROVAL_WEBHOOK_URL). Optional HMAC-SHA256 signing over `${timestamp}.${body}` with X-Gatehouse-Timestamp and X-Gatehouse-Signature headers when GATEHOUSE_APPROVAL_WEBHOOK_SECRET is set; 5-minute replay window. - AppRole delete clears that identity's pending leases. Shared - enforceProxyGates() helper consumed by REST proxy and MCP proxy so the three gates (AppRole rate limit, per-secret rate limit, approval) stay consistent across surfaces. - AuthContext gains role_id (extracted from JWT payload in middleware). - New MCP tool gatehouse_request_access; gatehouse_status now returns pending_leases. - New SSE events lease_request_created and lease_status_changed, identity-scoped server-side. UI - Leases page: Active / Pending Approval tabs. Approve / Deny actions with justification preview. Sidebar pending badge updated via SSE. - AppRole modals: three rate-limit inputs. List row gets a small rate-limited badge when any limit is set. - Secrets tree: approval chip on gated secrets. Detail panel: banner + Active Approved Leases section. Migration - Schema-only. Six nullable columns on leases (status defaulting to 'approved' so every existing row stays valid) and three on app_roles. New index idx_leases_pending. Tests: 497 pass (up from 459). New: test/rateLimits.test.ts, test/leaseApproval.test.ts, test/leaseWebhook.test.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a2cdb74 commit 6663cda

23 files changed

Lines changed: 3179 additions & 85 deletions

‎CHANGELOG.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,36 @@ release notes (built from conventional-commit subjects between tags) carry the
55
fine-grained per-commit log. This file summarises each release at a higher
66
level.
77

8+
## [0.14.0] - 2026-05-12
9+
10+
### Features
11+
12+
- **Rate limits on proxy calls.** AppRoles gain three optional limits (per-minute, per-hour, per-day) settable from the UI or `POST/PUT /v1/auth/approle` body. Secrets accept a per-secret `metadata.rate_limit_per_minute`. Both apply independently; the most restrictive wins. Hitting either returns `429` with `Retry-After`. Root tokens and user JWTs are exempt. Counters are fixed-window and in-memory. Limit ceilings: 10K/min, 100K/hour, 1M/day.
13+
- **Approval-gated leases.** Mark a secret with `metadata.requires_approval=true` and agents must call `gatehouse_request_access(path, ttl, justification)` and wait for a human approval before `gatehouse_proxy` or `gatehouse_lease` will succeed. The approved lease IS the access window: revoking it kills access atomically. Status drives lifecycle (`pending` → `approved`/`denied`/`expired`). Auto-approve for trusted networks via `metadata.auto_approve_from_ip` (comma-separated CIDRs) plus optional `metadata.auto_approve_ttl_seconds`.
14+
- **Approval webhook.** Set `GATEHOUSE_APPROVAL_WEBHOOK_URL` to fire a POST on every `lease.request_created`. Payload includes `approve_url` and `ui_url` so the receiver can deep-link a Slack/Discord/PagerDuty bridge to the approval. Optional `GATEHOUSE_APPROVAL_WEBHOOK_SECRET` enables HMAC-SHA256 signing over `${timestamp}.${body}` with `X-Gatehouse-Timestamp` and `X-Gatehouse-Signature` headers (5-minute replay window). When unset, no signature headers (URL-secrecy mode).
15+
- **New MCP tool `gatehouse_request_access`.** Agents call this to ask for approval. `gatehouse_status` now returns `pending_leases` so agents can poll for status changes.
16+
- **UI: Leases page redesigned.** Tabs for Active and Pending Approval. Approve/Deny actions with justification preview. Sidebar Leases nav gains a pending-count badge driven by SSE. AppRole modals gain a Rate Limits group. Approval-gated secrets get a `🔒 approval` chip in the tree and a banner in the detail panel.
17+
- **SSE: `lease_request_created` and `lease_status_changed` events.** Live-update the pending tab and the sidebar badge without polling. Identity-scoped server-side (non-admins only see events for leases they own).
18+
19+
### New metadata keys
20+
21+
- `requires_approval` (`"true"` to gate the secret)
22+
- `auto_approve_from_ip` (comma-separated CIDRs)
23+
- `auto_approve_ttl_seconds` (default 300)
24+
- `rate_limit_per_minute` (positive integer; per-secret cap)
25+
26+
### New AppRole columns
27+
28+
- `rate_limit_per_minute`, `rate_limit_per_hour`, `rate_limit_per_day` (all nullable; NULL means no limit)
29+
30+
### New audit actions
31+
32+
`lease.request_created`, `lease.approved`, `lease.denied`, `lease.request_expired`, `lease.auto_approved`, `lease.access`, `proxy.blocked.rate_limit`, `proxy.blocked.approval`.
33+
34+
### Migration
35+
36+
Schema-only; safe on a live v0.13.2 database. Six nullable columns added to `leases` (with `status` defaulting to `'approved'` so every existing row stays valid) and three nullable columns to `app_roles`. New index `idx_leases_pending`. Existing proxy and lease behaviour is unchanged for secrets without the new metadata.
37+
838
## [0.13.2] - 2026-05-10
939

1040
### Documentation

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ For everything else (leasing, dynamic secrets, SSH certificates, audit logging)
2727
- **Key rotation.** Rotate the master key and re-wrap all DEKs in one API call, zero downtime.
2828
- **Web UI.** Dark-themed control panel with command palette (Cmd/Ctrl+K) for managing secrets, leases, agents, patterns, and audit logs.
2929
- **SSO.** Optional OpenID Connect single sign-on for human admins. Works with PocketID, Authentik, Keycloak, Google, and any other OIDC provider. Strict link-by-verified-email account model; configure once in Settings.
30+
- **Rate limits.** Per-AppRole (minute/hour/day) and per-secret (minute) on proxy traffic. Bound damage from a runaway agent or an expensive upstream. 429 with `Retry-After` when hit; root and user JWTs exempt.
31+
- **Approval-gated leases.** Mark a secret `requires_approval=true` and agents must call `gatehouse_request_access` and wait for a human. The approved lease IS the access window: revoke once, atomic kill. Optional signed webhook (HMAC + timestamp) for Slack/Discord/paging bridges. Trusted networks can auto-approve via CIDR allowlist.
3032
- **Homelab-first.** Single Docker container. Runs on a Raspberry Pi, Proxmox LXC, or Jetson Orin Nano. AGPL-3.0.
3133

3234
## Screenshots

‎src/api/auth.ts‎

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { safeEqual } from "../auth/middleware";
77
import { verifyTotp, verifyRecoveryCode } from "../auth/totp";
88
import { ipMatchesAllowlist, validateCIDRs } from "../auth/cidr";
99
import { generateLoginParams, buildAuthUrl, exchangeAndVerify, fetchUserinfo } from "../auth/oidc";
10+
import { validateAppRoleLimits } from "../rateLimits/rateLimitValidation";
11+
import type { LeaseManager } from "../lease/manager";
1012

1113
// Simple in-memory rate limiter: 5 failed attempts per IP per 60s window
1214
const failedAttempts = new Map<string, { count: number; resetAt: number }>();
@@ -55,7 +57,7 @@ function recordFailure(ip: string) {
5557
}
5658
}
5759

58-
export function authRouter(db: Database, config: GatehouseConfig, audit: AuditLog) {
60+
export function authRouter(db: Database, config: GatehouseConfig, audit: AuditLog, leases?: LeaseManager) {
5961
const router = new Hono();
6062
const secret = new TextEncoder().encode(config.jwtSecret);
6163

@@ -616,7 +618,7 @@ h1{font-size:18px;margin:0 0 12px}p{color:#9ca3af;margin:0 0 24px}a{color:#6c63f
616618
}
617619

618620
const roles = db
619-
.query("SELECT role_id, display_name, policies, suspended, created_at, last_used, ip_allowlist FROM app_roles ORDER BY created_at DESC")
621+
.query("SELECT role_id, display_name, policies, suspended, created_at, last_used, ip_allowlist, rate_limit_per_minute, rate_limit_per_hour, rate_limit_per_day FROM app_roles ORDER BY created_at DESC")
620622
.all() as any[];
621623

622624
return c.json({
@@ -628,6 +630,9 @@ h1{font-size:18px;margin:0 0 12px}p{color:#9ca3af;margin:0 0 24px}a{color:#6c63f
628630
created_at: r.created_at,
629631
last_used: r.last_used,
630632
ip_allowlist: r.ip_allowlist ? JSON.parse(r.ip_allowlist) : [],
633+
rate_limit_per_minute: r.rate_limit_per_minute,
634+
rate_limit_per_hour: r.rate_limit_per_hour,
635+
rate_limit_per_day: r.rate_limit_per_day,
631636
})),
632637
});
633638
});
@@ -644,7 +649,14 @@ h1{font-size:18px;margin:0 0 12px}p{color:#9ca3af;margin:0 0 24px}a{color:#6c63f
644649
return c.json({ error: "AppRole not found", request_id: c.get("requestId") }, 404);
645650
}
646651

647-
let body: { display_name?: string; policies?: string[]; ip_allowlist?: string[] };
652+
let body: {
653+
display_name?: string;
654+
policies?: string[];
655+
ip_allowlist?: string[];
656+
rate_limit_per_minute?: unknown;
657+
rate_limit_per_hour?: unknown;
658+
rate_limit_per_day?: unknown;
659+
};
648660
try {
649661
body = await c.req.json();
650662
} catch {
@@ -672,19 +684,55 @@ h1{font-size:18px;margin:0 0 12px}p{color:#9ca3af;margin:0 0 24px}a{color:#6c63f
672684
allowlistJson = cleaned.length > 0 ? JSON.stringify(cleaned) : null;
673685
}
674686

675-
if (allowlistJson !== undefined) {
687+
// Rate limits: only update if any field was provided. Missing-field semantics
688+
// are "leave unchanged"; explicit null sets the column to NULL (no limit).
689+
const hasLimitFields =
690+
body.rate_limit_per_minute !== undefined ||
691+
body.rate_limit_per_hour !== undefined ||
692+
body.rate_limit_per_day !== undefined;
693+
let limits: { per_minute: number | null; per_hour: number | null; per_day: number | null } | undefined;
694+
if (hasLimitFields) {
695+
const v = validateAppRoleLimits({
696+
rate_limit_per_minute: body.rate_limit_per_minute,
697+
rate_limit_per_hour: body.rate_limit_per_hour,
698+
rate_limit_per_day: body.rate_limit_per_day,
699+
});
700+
if (!v.ok) {
701+
return c.json({ error: v.error, request_id: c.get("requestId") }, 400);
702+
}
703+
limits = v.limits;
704+
}
705+
706+
if (allowlistJson !== undefined && limits) {
707+
db.query(
708+
"UPDATE app_roles SET display_name = ?, policies = ?, ip_allowlist = ?, rate_limit_per_minute = ?, rate_limit_per_hour = ?, rate_limit_per_day = ? WHERE role_id = ?"
709+
).run(displayName, JSON.stringify(policies), allowlistJson, limits.per_minute, limits.per_hour, limits.per_day, roleId);
710+
} else if (allowlistJson !== undefined) {
676711
db.query("UPDATE app_roles SET display_name = ?, policies = ?, ip_allowlist = ? WHERE role_id = ?")
677712
.run(displayName, JSON.stringify(policies), allowlistJson, roleId);
713+
} else if (limits) {
714+
db.query(
715+
"UPDATE app_roles SET display_name = ?, policies = ?, rate_limit_per_minute = ?, rate_limit_per_hour = ?, rate_limit_per_day = ? WHERE role_id = ?"
716+
).run(displayName, JSON.stringify(policies), limits.per_minute, limits.per_hour, limits.per_day, roleId);
678717
} else {
679718
db.query("UPDATE app_roles SET display_name = ?, policies = ? WHERE role_id = ?")
680719
.run(displayName, JSON.stringify(policies), roleId);
681720
}
682721

722+
const updated = db
723+
.query(
724+
"SELECT rate_limit_per_minute, rate_limit_per_hour, rate_limit_per_day FROM app_roles WHERE role_id = ?"
725+
)
726+
.get(roleId) as any;
727+
683728
return c.json({
684729
role_id: roleId,
685730
display_name: displayName,
686731
policies,
687732
ip_allowlist: allowlistJson ? JSON.parse(allowlistJson) : [],
733+
rate_limit_per_minute: updated?.rate_limit_per_minute ?? null,
734+
rate_limit_per_hour: updated?.rate_limit_per_hour ?? null,
735+
rate_limit_per_day: updated?.rate_limit_per_day ?? null,
688736
});
689737
});
690738

@@ -725,11 +773,20 @@ h1{font-size:18px;margin:0 0 12px}p{color:#9ca3af;margin:0 0 24px}a{color:#6c63f
725773
}
726774

727775
const roleId = c.req.param("roleId");
728-
const role = db.query("SELECT role_id FROM app_roles WHERE role_id = ?").get(roleId);
776+
const role = db.query("SELECT role_id, display_name FROM app_roles WHERE role_id = ?").get(roleId) as { role_id: string; display_name: string } | null;
729777
if (!role) {
730778
return c.json({ error: "AppRole not found", request_id: c.get("requestId") }, 404);
731779
}
732780

781+
// Cancel any pending lease requests this AppRole has in flight so they
782+
// don't haunt the approval queue after the role is gone. Approved leases
783+
// are left alone (they keep their existing revoked-or-expiring lifecycle
784+
// and stay visible in audit).
785+
if (leases) {
786+
const identity = `approle:${role.display_name}`;
787+
leases.deletePendingForIdentity(identity);
788+
}
789+
733790
db.query("DELETE FROM app_roles WHERE role_id = ?").run(roleId);
734791
return c.json({ deleted: true });
735792
});
@@ -1066,7 +1123,14 @@ h1{font-size:18px;margin:0 0 12px}p{color:#9ca3af;margin:0 0 24px}a{color:#6c63f
10661123
return c.json({ error: "Admin access required", request_id: c.get("requestId") }, 403);
10671124
}
10681125

1069-
let body: { display_name: string; policies: string[]; ip_allowlist?: string[] };
1126+
let body: {
1127+
display_name: string;
1128+
policies: string[];
1129+
ip_allowlist?: string[];
1130+
rate_limit_per_minute?: unknown;
1131+
rate_limit_per_hour?: unknown;
1132+
rate_limit_per_day?: unknown;
1133+
};
10701134
try {
10711135
body = await c.req.json();
10721136
} catch {
@@ -1093,13 +1157,31 @@ h1{font-size:18px;margin:0 0 12px}p{color:#9ca3af;margin:0 0 24px}a{color:#6c63f
10931157
allowlistJson = cleaned.length > 0 ? JSON.stringify(cleaned) : null;
10941158
}
10951159

1160+
const limitsResult = validateAppRoleLimits({
1161+
rate_limit_per_minute: body.rate_limit_per_minute,
1162+
rate_limit_per_hour: body.rate_limit_per_hour,
1163+
rate_limit_per_day: body.rate_limit_per_day,
1164+
});
1165+
if (!limitsResult.ok) {
1166+
return c.json({ error: limitsResult.error, request_id: c.get("requestId") }, 400);
1167+
}
1168+
10961169
const role_id = `role-${crypto.randomUUID()}`;
10971170
const secret_id = crypto.randomUUID();
10981171
const secret_hash = await Bun.password.hash(secret_id);
10991172

11001173
db.query(
1101-
"INSERT INTO app_roles (role_id, secret_hash, display_name, policies, ip_allowlist) VALUES (?, ?, ?, ?, ?)"
1102-
).run(role_id, secret_hash, display_name, JSON.stringify(policies), allowlistJson);
1174+
"INSERT INTO app_roles (role_id, secret_hash, display_name, policies, ip_allowlist, rate_limit_per_minute, rate_limit_per_hour, rate_limit_per_day) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
1175+
).run(
1176+
role_id,
1177+
secret_hash,
1178+
display_name,
1179+
JSON.stringify(policies),
1180+
allowlistJson,
1181+
limitsResult.limits.per_minute,
1182+
limitsResult.limits.per_hour,
1183+
limitsResult.limits.per_day
1184+
);
11031185

11041186
return c.json(
11051187
{
@@ -1108,6 +1190,9 @@ h1{font-size:18px;margin:0 0 12px}p{color:#9ca3af;margin:0 0 24px}a{color:#6c63f
11081190
display_name,
11091191
policies,
11101192
ip_allowlist: allowlistJson ? JSON.parse(allowlistJson) : [],
1193+
rate_limit_per_minute: limitsResult.limits.per_minute,
1194+
rate_limit_per_hour: limitsResult.limits.per_hour,
1195+
rate_limit_per_day: limitsResult.limits.per_day,
11111196
warning: "Save the secret_id now, it cannot be retrieved later",
11121197
},
11131198
201

0 commit comments

Comments
 (0)