33import java .nio .file .FileSystems ;
44import java .nio .file .PathMatcher ;
55import java .nio .file .Paths ;
6+ import java .util .Comparator ;
67import java .util .List ;
78import java .util .concurrent .ConcurrentHashMap ;
89import java .util .regex .Pattern ;
1920 * <p>Evaluation order:
2021 *
2122 * <ol>
22- * <li>Config deny rules — first match returns {@link Result.Denied}.
23+ * <li>Config rules — evaluated in ascending {@code order}, first match wins (allow or deny). This is firewall /
24+ * iptables semantics: a lower-numbered allow rule beats a higher-numbered deny rule and vice versa.
2325 * <li>DB deny rules — first match returns {@link Result.Denied}.
24- * <li>If no allow rules are configured anywhere — returns {@link Result.OpenMode} (implicitly allowed).
25- * <li>Config allow rules — first match returns {@link Result.Allowed}.
2626 * <li>DB allow rules — first match returns {@link Result.Allowed}.
27- * <li>Allow rules exist but none matched — returns {@link Result.NotAllowed}.
27+ * <li>No rule matched — returns {@link Result.NotAllowed} (fail-closed) .
2828 * </ol>
2929 *
30- * <p>DB rules are fetched once per evaluation (not twice), then split into deny/allow lists in memory.
30+ * <p>DB rules are fetched once per evaluation (not twice), then split into deny/allow lists in memory. DB rules do not
31+ * carry an {@code order} field and are therefore evaluated after all config rules.
3132 */
3233@ Slf4j
3334public class UrlRuleEvaluator {
3435
3536 /** Outcome of a single rule evaluation pass. */
36- public sealed interface Result permits Result .Denied , Result .Allowed , Result .OpenMode , Result . NotAllowed {
37+ public sealed interface Result permits Result .Denied , Result .Allowed , Result .NotAllowed {
3738
3839 /** A deny rule matched — request must be rejected. */
3940 record Denied (String ruleId ) implements Result {}
4041
4142 /** An allow rule matched — request may proceed. */
4243 record Allowed (String ruleId ) implements Result {}
4344
44- /**
45- * No allow rules are configured anywhere (neither config nor DB). The proxy is in open/permissive mode and the
46- * request may proceed.
47- */
48- record OpenMode () implements Result {}
49-
5045 /** Allow rules are configured but none matched — request must be rejected. */
5146 record NotAllowed () implements Result {}
5247 }
@@ -58,7 +53,11 @@ record NotAllowed() implements Result {}
5853 private final GitProxyProvider provider ;
5954
6055 public UrlRuleEvaluator (List <UrlRuleFilter > configRules , RepoRegistry repoRegistry , GitProxyProvider provider ) {
61- this .configRules = configRules != null ? configRules : List .of ();
56+ this .configRules = configRules != null
57+ ? configRules .stream ()
58+ .sorted (Comparator .comparingInt (UrlRuleFilter ::getOrder ))
59+ .toList ()
60+ : List .of ();
6261 this .repoRegistry = repoRegistry ;
6362 this .provider = provider ;
6463 }
@@ -79,13 +78,23 @@ public Result evaluate(String slug, String owner, String name, HttpOperation ope
7978 ? repoRegistry .findEnabledForProvider (provider .getProviderId ())
8079 : List .of ();
8180
82- // ── Step 1: deny rules ────────────────────────────────────────── ───────
81+ // ── Step 1: config rules — single ordered pass, first match wins ───────
8382 for (UrlRuleFilter f : configRules ) {
84- if (f .getAccess () == AccessRule .Access .DENY && f .appliesTo (operation ) && f .matchesRepo (slug , owner , name )) {
85- log .debug ("Denied by config rule: {}" , f );
83+ if (!f .appliesTo (operation ) || !f .matchesRepo (slug , owner , name )) continue ;
84+ if (f .getAccess () == AccessRule .Access .DENY ) {
85+ log .debug ("Denied by config rule (order {}): {}" , f .getOrder (), f );
8686 return new Result .Denied (f .toString ());
87+ } else {
88+ log .debug ("Allowed by config rule (order {}): {}" , f .getOrder (), f );
89+ return new Result .Allowed (f .toString ());
8790 }
8891 }
92+
93+ // ── Step 2: DB rules — deny first, then allow ──────────────────────────
94+ List <AccessRule > dbAllow = dbRules .stream ()
95+ .filter (r -> r .getAccess () == AccessRule .Access .ALLOW && operationMatches (r , operation ))
96+ .toList ();
97+
8998 for (AccessRule rule : dbRules ) {
9099 if (rule .getAccess () == AccessRule .Access .DENY
91100 && operationMatches (rule , operation )
@@ -95,25 +104,7 @@ && matchesRepo(rule, slug, owner, name)) {
95104 }
96105 }
97106
98- // ── Step 2: allow rules ────────────────────────────────────────────────
99- List <UrlRuleFilter > configAllow = configRules .stream ()
100- .filter (f -> f .getAccess () == AccessRule .Access .ALLOW && f .appliesTo (operation ))
101- .toList ();
102- List <AccessRule > dbAllow = dbRules .stream ()
103- .filter (r -> r .getAccess () == AccessRule .Access .ALLOW && operationMatches (r , operation ))
104- .toList ();
105-
106- if (configAllow .isEmpty () && dbAllow .isEmpty ()) {
107- log .debug ("No allow rules configured for operation {} — open mode" , operation );
108- return new Result .OpenMode ();
109- }
110-
111- for (UrlRuleFilter f : configAllow ) {
112- if (f .matchesRepo (slug , owner , name )) {
113- log .debug ("Allowed by config rule: {}" , f );
114- return new Result .Allowed (f .toString ());
115- }
116- }
107+ // ── Step 3: allow rules matched ────────────────────────
117108 for (AccessRule rule : dbAllow ) {
118109 if (matchesRepo (rule , slug , owner , name )) {
119110 log .debug ("Allowed by DB rule: id={}" , rule .getId ());
0 commit comments