Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
4ca3f5d
chore(deps): add deep-diff for structural audit diffs
joelorzet Jun 4, 2026
e7083cc
feat(security): add security audit registry and API-key change alerts
joelorzet Jun 4, 2026
7cf1d3a
feat(security): record execution credential and workflow version on runs
joelorzet Jun 4, 2026
78702a5
feat(security): notify and audit org API key create/revoke
joelorzet Jun 4, 2026
f35d10c
feat(security): add security audit log read endpoint
joelorzet Jun 4, 2026
5903290
feat(security): audit sensitive auth and MFA actions
joelorzet Jun 4, 2026
b7cac92
feat(security): audit workflow and marketplace changes
joelorzet Jun 4, 2026
8fe2bb1
feat(security): audit billing subscription changes
joelorzet Jun 4, 2026
998ee00
feat(security): audit wallet creation and HMAC rotation
joelorzet Jun 4, 2026
1f76422
feat(workflow): capture per-version history snapshots on create/update
joelorzet Jun 4, 2026
78c2043
feat(workflow): version history read API + ?version= snapshot load
joelorzet Jun 4, 2026
28d43cb
feat(workflow): version history overlay in the editor (admin/owner)
joelorzet Jun 4, 2026
893f997
feat(security): per-key change history in the API-keys overlay
joelorzet Jun 4, 2026
330459c
refactor(workflow): readable semantic version diff + view-on-canvas
joelorzet Jun 4, 2026
400534d
fix(web3): accept legacy `args` key for read/write-contract on save
joelorzet Jun 4, 2026
58f5148
fix(web3): stop rejecting editor-persisted abiFunctionKey on save
joelorzet Jun 4, 2026
2b743a0
Merge branch 'staging' into feat/keep-671-api-token-email-alerts
joelorzet Jun 10, 2026
b80d6ff
feat(ui): Google-Docs-style version history & activity
joelorzet Jun 10, 2026
6eb42bb
feat(ui): sync version panel with config panel; animate + resize
joelorzet Jun 10, 2026
972ebfe
feat(api): shared HATEOAS cursor pagination
joelorzet Jun 10, 2026
63988e6
feat(api): numbered offset pagination with totals + reusable client hook
joelorzet Jun 10, 2026
fcef98c
fix(ui): hide version-panel handle when the panel is closed
joelorzet Jun 10, 2026
d0a0a36
fix(ui): align version panel top with the node-config panel
joelorzet Jun 10, 2026
899ff0f
feat(workflow): show per-version changes; slow autosave; panel polish
joelorzet Jun 10, 2026
ddb6a4f
fix(workflow): paginate version panel; skip no-op versions
joelorzet Jun 10, 2026
7da999d
feat(workflow): collapsible version rows, shareable ?version= preview
joelorzet Jun 10, 2026
9a07604
feat(workflow): show before/after values for node config changes
joelorzet Jun 10, 2026
436114e
feat(workflow): readable config diffs (field labels, clean refs, layout)
joelorzet Jun 10, 2026
d943184
style(workflow): semantic before/after diff chips in version history
joelorzet Jun 10, 2026
e23736c
feat(workflow): clearer version diffs, polish, and 30s polling
joelorzet Jun 10, 2026
b5b208c
feat(workflow): humanize version-diff values + polish
joelorzet Jun 10, 2026
3f51f91
feat(workflow): shareable version-history page in the URL
joelorzet Jun 10, 2026
a8b8865
feat: KEEP-695 generalize security audit log for deletion cascades
suisuss Jun 11, 2026
8bdcd32
test: KEEP-695 adapt user-delete route test to audit cascade writes
suisuss Jun 11, 2026
98045e9
fix: surface durable actor_label in audit read when the user is gone
suisuss Jun 11, 2026
63c85f0
feat: count dropped security audit writes as a metric
suisuss Jun 11, 2026
b225ab2
docs: note the atomic-audit asymmetry at the deactivation cascade
suisuss Jun 11, 2026
20b4a9b
docs: document the redaction contract on audit before/after
suisuss Jun 11, 2026
6e73eb6
test: KEEP-695 stub server-only in version-history suites
suisuss Jun 11, 2026
702c355
Merge branch 'staging' into feat/keep-671-api-token-email-alerts
joelorzet Jun 12, 2026
66baa0d
feat(security): key-activity modal, richer actors, empty-state fallbacks
joelorzet Jun 12, 2026
48260bd
feat(security): audit integration changes (add/update/remove)
joelorzet Jun 12, 2026
6790711
feat: add audit action vocabulary for org, member, project, tag, and …
joelorzet Jun 12, 2026
e458830
feat: audit admin user and organization activate/deactivate
joelorzet Jun 12, 2026
009a124
feat: audit agentic-wallet approvals and private-key export
joelorzet Jun 12, 2026
938bc84
feat: audit organization settings and member lifecycle changes
joelorzet Jun 12, 2026
a7992c7
feat: audit project and tag changes with per-resource activity history
joelorzet Jun 12, 2026
1bcb8da
feat: allow admins to rename projects and tags from the manager
joelorzet Jun 12, 2026
fefbdef
feat: show what changed (old to new) in the activity feed, masking se…
joelorzet Jun 12, 2026
09751f3
fix: solid reusable activity status badge and drop redundant value li…
joelorzet Jun 12, 2026
b9e89ce
style: shrink activity badge icon
joelorzet Jun 12, 2026
80408ca
feat: audit workflow project/tag moves and go-live listing changes
joelorzet Jun 12, 2026
c123635
feat: audit color and description changes for tags and projects with …
joelorzet Jun 12, 2026
87aaa11
style: show color changes as a swatch only, not the hex value
joelorzet Jun 12, 2026
f91e8e8
feat: paginate per-resource activity feeds at 5 events per page
joelorzet Jun 12, 2026
1034c7e
fix: keep activity feed mounted on refetch and enlarge skeletons to s…
joelorzet Jun 12, 2026
a347999
fix: fixed-height activity feed with skeletons on every page change, …
joelorzet Jun 12, 2026
aee4e90
fix(security): scope audit API response to display fields and redact …
joelorzet Jun 12, 2026
ff9a491
fix: restore pre-audit creation fallback on the last page for multi-k…
joelorzet Jun 12, 2026
7b7acbc
feat: add audit actions for session, workflow toggles, wallet sign/li…
joelorzet Jun 15, 2026
8723e9e
feat: audit sign-in events (session.created with ip and device)
joelorzet Jun 15, 2026
976f841
feat: audit admin workflow activate/deactivate
joelorzet Jun 15, 2026
5892bf9
feat: audit agentic-wallet sign/link and wallet withdrawals
joelorzet Jun 15, 2026
1c3336c
feat: audit Safe role install/update and allowance changes
joelorzet Jun 15, 2026
ba2ebb7
feat(security): alert high-risk audit actions to Sentry
joelorzet Jun 15, 2026
060db97
fix(security): drop userAgent from the audit response, keep ip and co…
joelorzet Jun 15, 2026
287078e
feat(security): make security_audit_log append-only within the retent…
joelorzet Jun 15, 2026
1617628
feat(security): add audit-log retention purge cron (730-day window)
joelorzet Jun 15, 2026
a7e3a4c
feat(security): admin CSV export of the audit log for compliance
joelorzet Jun 15, 2026
ad6eeb1
feat(security): email organization owners on high-risk audit actions
joelorzet Jun 15, 2026
aa92adc
test: cover owner-email alerting for high-risk audit actions
joelorzet Jun 15, 2026
3971da8
fix(security): neutralize CSV formula injection in audit export
joelorzet Jun 15, 2026
d4005ec
feat(security): backfill created-event baseline for org audit feed
joelorzet Jun 15, 2026
43cc9fc
feat(security): owner-only dual-factor gate for audit CSV export
joelorzet Jun 15, 2026
0667859
feat(security): server-side audit activity filter builder + feed refi…
joelorzet Jun 15, 2026
c69aae3
Merge branch 'staging' into feat/keep-671-api-token-email-alerts
joelorzet Jun 15, 2026
559c2a3
chore(db): consolidate KEEP-671 audit migrations into one
joelorzet Jun 15, 2026
e789127
test: update API key route tests for paginated, creator-enriched GET
joelorzet Jun 15, 2026
759402f
feat: surface org activity audit in the left nav
joelorzet Jun 16, 2026
f743bb6
feat: move workflow version history into the editor right-panel tab
joelorzet Jun 16, 2026
773fb62
fix: backfill workflow version-history creation baselines
joelorzet Jun 16, 2026
264d72f
Merge branch 'staging' into feat/keep-671-api-token-email-alerts
joelorzet Jun 16, 2026
5c445e8
fix(deploy): make workflow-history backfill non-fatal, drop it from P…
joelorzet Jun 16, 2026
dcdb65b
feat: link audit activity feed to resource history
joelorzet Jun 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,8 @@ minimum-release-age-exclude[]=monaco-editor
# Pinned to 1.4.4 (2019); the policy exclusion is only because pnpm
# checks the latest published version of every package on resolve.
minimum-release-age-exclude[]=qrcode-generator
# Structural JSON diff used for the security audit log (who-changed-what).
# Pinned to 1.0.2 (2018); stable, zero transitive deps. The exclusion is
# only needed because pnpm checks the latest published version on resolve.
minimum-release-age-exclude[]=deep-diff
minimum-release-age-exclude[]=@types/deep-diff
14 changes: 14 additions & 0 deletions app/api/admin/orgs/[orgId]/activate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { db } from "@/lib/db";
import { organization, workflows } from "@/lib/db/schema";
import { authenticateKhAdmin } from "@/lib/kh-admin-auth";
import { ErrorCategory, logSystemError } from "@/lib/logging";
import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log";

export async function POST(
request: Request,
Expand Down Expand Up @@ -59,6 +60,19 @@ export async function POST(
return NextResponse.json({ error: "Organization is not deactivated" }, { status: 409 });
}

await recordAuditEvent({
actor: {
userId: null,
organizationId: result.orgId,
authMethod: "kh-admin",
actorLabel: "KeeperHub admin",
},
action: "org.reactivated",
resourceType: "organization",
resourceId: result.orgId,
metadata: buildAuditMetadata(request),
});

return NextResponse.json(result);
} catch (error) {
logSystemError(ErrorCategory.DATABASE, "[Admin] Failed to activate org", error, {
Expand Down
18 changes: 18 additions & 0 deletions app/api/admin/orgs/[orgId]/deactivate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { db } from "@/lib/db";
import { organization, workflows } from "@/lib/db/schema";
import { authenticateKhAdmin } from "@/lib/kh-admin-auth";
import { ErrorCategory, logSystemError } from "@/lib/logging";
import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log";

export async function POST(
request: Request,
Expand Down Expand Up @@ -64,6 +65,23 @@ export async function POST(
);
}

await recordAuditEvent({
actor: {
userId: null,
organizationId: result.orgId,
authMethod: "kh-admin",
actorLabel: "KeeperHub admin",
},
action: "org.deactivated",
resourceType: "organization",
resourceId: result.orgId,
after: {
deactivatedAt: result.deactivatedAt?.toISOString(),
workflowsDeactivated: result.workflowsDeactivated,
},
metadata: buildAuditMetadata(request),
});

return NextResponse.json(result);
} catch (error) {
logSystemError(ErrorCategory.DATABASE, "[Admin] Failed to deactivate org", error, {
Expand Down
14 changes: 14 additions & 0 deletions app/api/admin/users/[userId]/activate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { db } from "@/lib/db";
import { users } from "@/lib/db/schema";
import { authenticateKhAdmin } from "@/lib/kh-admin-auth";
import { ErrorCategory, logSystemError } from "@/lib/logging";
import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log";

export async function POST(
request: Request,
Expand Down Expand Up @@ -48,6 +49,19 @@ export async function POST(
return NextResponse.json({ error: "User is not deactivated" }, { status: 409 });
}

await recordAuditEvent({
actor: {
userId: null,
organizationId: null,
authMethod: "kh-admin",
actorLabel: "KeeperHub admin",
},
action: "account.reactivated",
resourceType: "user",
resourceId: result.userId,
metadata: buildAuditMetadata(request),
});

return NextResponse.json(result);
} catch (error) {
logSystemError(ErrorCategory.DATABASE, "[Admin] Failed to activate user", error, {
Expand Down
15 changes: 15 additions & 0 deletions app/api/admin/users/[userId]/deactivate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { organizationApiKeys, sessions, users } from "@/lib/db/schema";
import { sendAccountDeactivatedEmail } from "@/lib/email";
import { authenticateKhAdmin } from "@/lib/kh-admin-auth";
import { ErrorCategory, logSystemError } from "@/lib/logging";
import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log";

// The body is optional; callers (raita-bot auto-block, on-call tooling) may
// POST nothing at all. notifyUser defaults to true so a deactivated user is
Expand Down Expand Up @@ -109,6 +110,20 @@ export async function POST(
}
}

await recordAuditEvent({
actor: {
userId: null,
organizationId: null,
authMethod: "kh-admin",
actorLabel: "KeeperHub admin",
},
action: "account.deactivated",
resourceType: "user",
resourceId: result.userId,
after: { deactivatedAt: result.deactivatedAt.toISOString() },
metadata: buildAuditMetadata(request),
});

return NextResponse.json({
userId: result.userId,
deactivatedAt: result.deactivatedAt,
Expand Down
14 changes: 14 additions & 0 deletions app/api/admin/workflows/[workflowId]/activate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { db } from "@/lib/db";
import { workflows } from "@/lib/db/schema";
import { authenticateKhAdmin } from "@/lib/kh-admin-auth";
import { ErrorCategory, logSystemError } from "@/lib/logging";
import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log";

export async function POST(
request: Request,
Expand Down Expand Up @@ -53,6 +54,19 @@ export async function POST(
return NextResponse.json({ error: "Workflow is not deactivated" }, { status: 409 });
}

await recordAuditEvent({
actor: {
userId: null,
organizationId: null,
authMethod: "kh-admin",
actorLabel: "KeeperHub admin",
},
action: "workflow.reactivated",
resourceType: "workflow",
resourceId: result.workflowId,
metadata: buildAuditMetadata(request),
});

return NextResponse.json(result);
} catch (error) {
logSystemError(ErrorCategory.DATABASE, "[Admin] Failed to activate workflow", error, {
Expand Down
15 changes: 15 additions & 0 deletions app/api/admin/workflows/[workflowId]/deactivate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { db } from "@/lib/db";
import { workflows } from "@/lib/db/schema";
import { authenticateKhAdmin } from "@/lib/kh-admin-auth";
import { ErrorCategory, logSystemError } from "@/lib/logging";
import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log";

export async function POST(
request: Request,
Expand Down Expand Up @@ -51,6 +52,20 @@ export async function POST(
return NextResponse.json({ error: "Workflow is already deactivated" }, { status: 409 });
}

await recordAuditEvent({
actor: {
userId: null,
organizationId: null,
authMethod: "kh-admin",
actorLabel: "KeeperHub admin",
},
action: "workflow.deactivated",
resourceType: "workflow",
resourceId: result.workflowId,
after: { deactivatedAt: result.deactivatedAt?.toISOString() },
metadata: buildAuditMetadata(request),
});

return NextResponse.json(result);
} catch (error) {
logSystemError(ErrorCategory.DATABASE, "[Admin] Failed to deactivate workflow", error, {
Expand Down
13 changes: 13 additions & 0 deletions app/api/agentic-wallet/[id]/approve/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
requireDualFactor,
} from "@/lib/mfa/dual-factor";
import { requireMfaEnrolled } from "@/lib/middleware/owner-mfa-guard";
import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -159,6 +160,18 @@ export async function POST(
{ status: 409 }
);
}
await recordAuditEvent({
actor: {
userId,
organizationId: null,
authMethod: "session",
},
action: "agentic_wallet.approval_granted",
resourceType: "agentic_wallet",
resourceId: id,
metadata: buildAuditMetadata(request),
});

return NextResponse.json({ ok: true, status: "approved" });
} catch (error) {
logSystemError(ErrorCategory.DATABASE, "[Agentic] /approve failed", error, {
Expand Down
13 changes: 13 additions & 0 deletions app/api/agentic-wallet/[id]/reject/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
requireDualFactor,
} from "@/lib/mfa/dual-factor";
import { requireMfaEnrolled } from "@/lib/middleware/owner-mfa-guard";
import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -137,6 +138,18 @@ export async function POST(
{ status: 409 }
);
}
await recordAuditEvent({
actor: {
userId,
organizationId: null,
authMethod: "session",
},
action: "agentic_wallet.approval_denied",
resourceType: "agentic_wallet",
resourceId: id,
metadata: buildAuditMetadata(request),
});

return NextResponse.json({ ok: true, status: "rejected" });
} catch (error) {
logSystemError(ErrorCategory.DATABASE, "[Agentic] /reject failed", error, {
Expand Down
10 changes: 10 additions & 0 deletions app/api/agentic-wallet/link/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { agenticWallets } from "@/lib/db/schema";
import { ErrorCategory, logSystemError } from "@/lib/logging";
import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -137,6 +138,15 @@ export async function POST(request: Request): Promise<Response> {
);
}

await recordAuditEvent({
actor: { userId, organizationId: null, authMethod: "session" },
action: "agentic_wallet.linked",
resourceType: "agentic_wallet",
resourceId: subOrgId,
after: { linkedUserId: userId },
metadata: buildAuditMetadata(request),
});

return Response.json({ ok: true }, { status: 200 });
} catch (error) {
logSystemError(ErrorCategory.DATABASE, "[Agentic] /link failed", error, {
Expand Down
15 changes: 15 additions & 0 deletions app/api/agentic-wallet/rotate-hmac/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { insertHmacSecret } from "@/lib/agentic-wallet/hmac-secret-store";
import { db } from "@/lib/db";
import { agenticWalletHmacSecrets } from "@/lib/db/schema";
import { ErrorCategory, logSystemError } from "@/lib/logging";
import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -82,6 +83,20 @@ export async function POST(request: Request): Promise<Response> {
)
);

await recordAuditEvent({
actor: {
userId: null,
organizationId: null,
authMethod: "internal",
},
action: "agentic_wallet.hmac_rotated",
resourceType: "agentic_wallet",
resourceId: auth.subOrgId,
before: { keyVersion: currentVersion },
after: { keyVersion: newVersion },
metadata: buildAuditMetadata(request),
});

return Response.json(
{ newSecret, keyVersion: newVersion },
{ status: 200 }
Expand Down
19 changes: 19 additions & 0 deletions app/api/agentic-wallet/sign/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { verifyWorkflowBinding } from "@/lib/agentic-wallet/workflow-binding";
import { db } from "@/lib/db";
import { agenticWallets } from "@/lib/db/schema";
import { ErrorCategory, logSystemError } from "@/lib/logging";
import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -395,6 +396,24 @@ export async function POST(request: Request): Promise<Response> {
});
}
}
// Auto-tier signature emitted. Record the operation (never the signature)
// so the wallet's signing activity is auditable. Ask-tier signs only after
// an approval, which is audited separately on /approve.
await recordAuditEvent({
actor: { userId: null, organizationId: null, authMethod: "internal" },
action: "agentic_wallet.signed",
resourceType: "agentic_wallet",
resourceId: auth.subOrgId,
after: {
chain,
risk,
walletAddress,
amountMicro: binding.expectedAmountMicro,
payTo: callerPayTo,
},
metadata: buildAuditMetadata(request),
});

return Response.json({ signature }, { status: 200 });
} catch (error) {
// Fix-pack-3 N-2: only refund quota on TRANSIENT upstream failures. A
Expand Down
36 changes: 33 additions & 3 deletions app/api/api-keys/[keyId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
requireDualFactor,
} from "@/lib/mfa/dual-factor";
import { requireMfaEnrolled } from "@/lib/middleware/owner-mfa-guard";
import { notifyApiKeyChange } from "@/lib/security/api-key-notification";
import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log";

// DELETE - Delete an API key
export async function DELETE(
Expand Down Expand Up @@ -58,15 +60,43 @@ export async function DELETE(
}

// Delete the key (only if it belongs to the user)
const result = await db
const [deleted] = await db
.delete(apiKeys)
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, session.user.id)))
.returning({ id: apiKeys.id });
.returning({
id: apiKeys.id,
name: apiKeys.name,
keyPrefix: apiKeys.keyPrefix,
});

if (result.length === 0) {
if (!deleted) {
return NextResponse.json({ error: "API key not found" }, { status: 404 });
}

// Out-of-band alert symmetric with creation: the owner learns a bypass
// credential was revoked even if their own session did it. Non-blocking.
notifyApiKeyChange({
email: session.user.email,
action: "revoked",
tokenName: deleted.name,
keyPrefix: deleted.keyPrefix,
when: new Date(),
});

// Durable forensic record of who revoked the key and from where.
await recordAuditEvent({
actor: {
userId: session.user.id,
organizationId: null,
authMethod: "session",
},
action: "api_key.revoked",
resourceType: "api_key",
resourceId: deleted.id,
before: { name: deleted.name, keyPrefix: deleted.keyPrefix },
metadata: buildAuditMetadata(request),
});

return NextResponse.json({ success: true });
} catch (error) {
logSystemError(ErrorCategory.DATABASE, "Failed to delete API key", error, {
Expand Down
Loading
Loading