Skip to content

Commit c3b0b28

Browse files
khalidsheetclaude
andcommitted
feat(files): rule-based file protection — per-field rules + one-time + IP-bound + audit
- viewRule on file FieldOptions, AND-combined with collection view_rule - requireAuth blocks anonymous fetches even on public collections - oneTimeToken: jti-keyed replay guard (vaultbase_file_token_uses), 410 on reuse - bindTokenIp: JWT carries ip claim, mismatched fetch returns 403 - auditDownloads: appends files.download row to vaultbase_audit_log - Extra rule operands via @request.headers.x_vb_*: ip, file_size, file_mime, file_field, collection - Admin schema editor: textarea + 4 toggles under "Download protection" - 12 new tests; full suite 608 passing Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4d87ef1 commit c3b0b28

9 files changed

Lines changed: 766 additions & 72 deletions

File tree

admin/src/components/Shell.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const Sidebar: React.FC = () => {
4747
<VaultbaseLogo size={22} />
4848
</span>
4949
<div className="sb-brand-name">vaultbase</div>
50-
<div className="sb-brand-version mono">v0.5.0</div>
50+
<div className="sb-brand-version mono">v0.6.0</div>
5151
</div>
5252
{SECTIONS.map((sec) => (
5353
<div className="sb-section" key={sec.label}>

admin/src/pages/CollectionEdit.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,80 @@ function FieldOptionsBody({
10321032
onChange={(v) => updateSelOptions({ protected: v })}
10331033
/>
10341034
</div>
1035+
1036+
{/* ── Rule-based file protection ─────────────────────────────── */}
1037+
<div style={{ marginTop: 4, paddingTop: 12, borderTop: "1px solid rgba(255,255,255,0.06)" }}>
1038+
<div style={{ fontSize: 12, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5, color: "var(--vb-muted)", marginBottom: 8 }}>
1039+
Download protection
1040+
</div>
1041+
</div>
1042+
<div>
1043+
<label className="label">Download rule</label>
1044+
<textarea
1045+
className="input mono"
1046+
rows={3}
1047+
value={(sel.options?.["viewRule"] as string | undefined) ?? ""}
1048+
onChange={(e) => {
1049+
const v = e.target.value;
1050+
updateSelOptions({ viewRule: v === "" ? undefined : v });
1051+
}}
1052+
placeholder="@auth.id != '' && @auth.id = record.owner"
1053+
style={{ width: "100%", resize: "vertical", fontSize: 12 }}
1054+
/>
1055+
<div className="muted" style={{ fontSize: 11, marginTop: 4 }}>
1056+
Per-field rule, AND-combined with the collection's view rule.
1057+
Empty = inherit collection rule. Extra context: <span className="mono">@request.headers.x_vb_ip</span>,
1058+
{" "}<span className="mono">x_vb_file_size</span>, <span className="mono">x_vb_file_mime</span>.
1059+
</div>
1060+
</div>
1061+
<div className="row" style={{ justifyContent: "space-between", alignItems: "flex-start", padding: "10px 12px", background: "rgba(255,255,255,0.03)", borderRadius: 7, gap: 12 }}>
1062+
<div style={{ flex: 1 }}>
1063+
<div style={{ fontSize: 13, fontWeight: 500 }}>Require authentication</div>
1064+
<div className="muted" style={{ fontSize: 11, marginTop: 2 }}>
1065+
Reject anonymous downloads even when the collection's view rule is public.
1066+
</div>
1067+
</div>
1068+
<Toggle
1069+
on={!!sel.options?.["requireAuth"]}
1070+
onChange={(v) => updateSelOptions({ requireAuth: v || undefined })}
1071+
/>
1072+
</div>
1073+
<div className="row" style={{ justifyContent: "space-between", alignItems: "flex-start", padding: "10px 12px", background: "rgba(255,255,255,0.03)", borderRadius: 7, gap: 12 }}>
1074+
<div style={{ flex: 1 }}>
1075+
<div style={{ fontSize: 13, fontWeight: 500 }}>One-time download token</div>
1076+
<div className="muted" style={{ fontSize: 11, marginTop: 2 }}>
1077+
Each token works for a single fetch. Replay returns <span className="mono">410 Gone</span>.
1078+
</div>
1079+
</div>
1080+
<Toggle
1081+
on={!!sel.options?.["oneTimeToken"]}
1082+
onChange={(v) => updateSelOptions({ oneTimeToken: v || undefined })}
1083+
/>
1084+
</div>
1085+
<div className="row" style={{ justifyContent: "space-between", alignItems: "flex-start", padding: "10px 12px", background: "rgba(255,255,255,0.03)", borderRadius: 7, gap: 12 }}>
1086+
<div style={{ flex: 1 }}>
1087+
<div style={{ fontSize: 13, fontWeight: 500 }}>Bind token to IP</div>
1088+
<div className="muted" style={{ fontSize: 11, marginTop: 2 }}>
1089+
Token rejects requests from a different IP. Incompatible with mobile NAT — opt-in.
1090+
</div>
1091+
</div>
1092+
<Toggle
1093+
on={!!sel.options?.["bindTokenIp"]}
1094+
onChange={(v) => updateSelOptions({ bindTokenIp: v || undefined })}
1095+
/>
1096+
</div>
1097+
<div className="row" style={{ justifyContent: "space-between", alignItems: "flex-start", padding: "10px 12px", background: "rgba(255,255,255,0.03)", borderRadius: 7, gap: 12 }}>
1098+
<div style={{ flex: 1 }}>
1099+
<div style={{ fontSize: 13, fontWeight: 500 }}>Audit downloads</div>
1100+
<div className="muted" style={{ fontSize: 11, marginTop: 2 }}>
1101+
Append a <span className="mono">files.download</span> row to the audit log per fetch.
1102+
</div>
1103+
</div>
1104+
<Toggle
1105+
on={!!sel.options?.["auditDownloads"]}
1106+
onChange={(v) => updateSelOptions({ auditDownloads: v || undefined })}
1107+
/>
1108+
</div>
10351109
</>
10361110
)}
10371111
</>

0 commit comments

Comments
 (0)