Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

push-policy.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

push-policy.tsBlame166 lines · 1 contributor
5dc83edClaude1/**
2 * Push-policy enforcement — runs at the HTTP layer before git-receive-pack
3 * actually accepts the pack.
4 *
5 * Until now Gluecron's protected-tag and ruleset surfaces were advisory:
6 * `src/hooks/post-receive.ts` would log audit entries after the push had
7 * already landed. This module flips them to truly blocking by evaluating
8 * a list of refs at receive time and returning {allowed:false, violations}
9 * so the route can short-circuit with a 403.
10 *
11 * Refs evaluable from name+sha alone (no pack inspection):
12 * - protected_tags — tag pushes must come from owner/bypass
13 * - ruleset.tag_name_pattern — disallow tag names matching pattern
14 * - ruleset.branch_name_pattern — disallow branch names matching pattern
15 * - ruleset.forbid_force_push — heuristic: detected when oldSha was
16 * not the zero-SHA and newSha differs.
17 * True force-push detection requires a
18 * reachability check we don't run here;
19 * the existing `forcePush` boolean on
20 * PushContext is wired to false unless
21 * a smarter caller fills it in.
22 *
23 * Pure helpers + DB callers; never throws into the request path. On any
24 * unexpected failure we return {allowed:true} (fail-open) to preserve the
25 * existing no-policy behaviour rather than wedging legitimate pushes when
26 * Postgres hiccups.
27 */
28
29import {
30 matchProtectedTag,
31 canBypassProtectedTag,
32} from "./protected-tags";
33import {
34 listRulesetsForRepo,
35 evaluatePush,
36 type PushContext,
37} from "./rulesets";
38
39export type RefUpdate = {
40 oldSha: string;
41 newSha: string;
42 refName: string;
43};
44
45export type PushPolicyArgs = {
46 repositoryId: string;
47 refs: RefUpdate[];
48 pusherUserId: string | null;
49};
50
51export type PushPolicyResult = {
52 allowed: boolean;
53 violations: string[];
54};
55
56/** "0000000000000000000000000000000000000000" — 40 zeros. */
57export const ZERO_SHA = "0".repeat(40);
58
59const ALLOW: PushPolicyResult = { allowed: true, violations: [] };
60
61/**
62 * Classify a ref name into "branch" / "tag" for the ruleset evaluator.
63 * Heads = branch, tags = tag, anything else (e.g. `refs/notes/*`) we treat
64 * as a branch since the evaluator's tag-only rules will gracefully no-op.
65 */
66function refType(refName: string): "branch" | "tag" {
67 return refName.startsWith("refs/tags/") ? "tag" : "branch";
68}
69
70/**
71 * Evaluate every ref in `refs` against the repo's protected-tags + rulesets
72 * and return the aggregated decision. Multiple violations across refs are
73 * concatenated so the user sees every problem in one push attempt rather
74 * than one-at-a-time.
75 */
76export async function evaluatePushPolicy(
77 args: PushPolicyArgs
78): Promise<PushPolicyResult> {
79 const { repositoryId, refs, pusherUserId } = args;
80 if (!repositoryId || !refs || refs.length === 0) return ALLOW;
81
82 const violations: string[] = [];
83
84 // Protected tags — runs once per ref, only fires for tag refs.
85 for (const ref of refs) {
86 if (!ref.refName.startsWith("refs/tags/")) continue;
87 const tagName = ref.refName.slice("refs/tags/".length);
88 let protectedRule: Awaited<ReturnType<typeof matchProtectedTag>> = null;
89 try {
90 protectedRule = await matchProtectedTag(repositoryId, tagName);
91 } catch {
92 protectedRule = null;
93 }
94 if (!protectedRule) continue;
95
96 // Anonymous pusher → never bypasses. Authenticated pusher must be the
97 // owner (or future tag-admin) for this repo.
98 let canBypass = false;
99 try {
100 canBypass = await canBypassProtectedTag(repositoryId, pusherUserId);
101 } catch {
102 canBypass = false;
103 }
104 if (canBypass) continue;
105
106 const action =
107 ref.newSha === ZERO_SHA
108 ? "delete"
109 : ref.oldSha === ZERO_SHA
110 ? "create"
111 : "update";
112 violations.push(
113 `tag "${tagName}" is protected (pattern: ${protectedRule.pattern}); ${action} requires bypass`
114 );
115 }
116
117 // Rulesets — single DB call, evaluator runs purely on names.
118 let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = [];
119 try {
120 rulesets = await listRulesetsForRepo(repositoryId);
121 } catch {
122 rulesets = [];
123 }
124
125 if (rulesets && rulesets.length > 0) {
126 for (const ref of refs) {
127 const ctx: PushContext = {
128 kind: "push",
129 refType: refType(ref.refName),
130 refName: ref.refName,
131 commits: [],
132 // forcePush is left false — true detection requires a reachability
133 // check on the new commit, which is in the pack we haven't unpacked.
134 forcePush: false,
135 };
136 let result;
137 try {
138 result = evaluatePush(rulesets, ctx);
139 } catch {
140 result = { allowed: true, violations: [] as Array<{ rulesetName: string; ruleType: string; message: string; enforcement: string }> };
141 }
142 if (!result.allowed && result.violations.length > 0) {
143 for (const v of result.violations) {
144 // Only "active" enforcement blocks; "evaluate" is dry-run.
145 if (v.enforcement !== "active") continue;
146 violations.push(
147 `ruleset "${v.rulesetName}" rule ${v.ruleType}: ${v.message} (ref ${ref.refName})`
148 );
149 }
150 }
151 }
152 }
153
154 return violations.length === 0
155 ? ALLOW
156 : { allowed: false, violations };
157}
158
159/** Build a human-readable error body for the 403 response. */
160export function formatPolicyError(violations: string[]): string {
161 if (!violations || violations.length === 0) {
162 return "Push rejected by Gluecron policy.";
163 }
164 const lines = violations.map((v) => ` - ${v}`);
165 return `Push rejected by Gluecron policy:\n${lines.join("\n")}\n`;
166}