Skip to content

Commit aa9732b

Browse files
committed
feat(internal): SecurityFilter happy path (single-scheme allow)
Adds SecurityFilter with OR-of-AND group evaluation. Satisfied groups update the ScopedValue binding with extracted principals. Rejection path (Task 9) stubs with UnsupportedOperationException. Includes ScopedValueHarness test helper and SecurityFilterTest covering the allowed and no-security cases.
1 parent fa3a490 commit aa9732b

3 files changed

Lines changed: 228 additions & 0 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package com.retailsvc.http.internal;
2+
3+
import com.retailsvc.http.Request;
4+
import com.retailsvc.http.SchemeValidator;
5+
import com.retailsvc.http.spec.Operation;
6+
import com.retailsvc.http.spec.security.SecurityRequirement;
7+
import com.retailsvc.http.spec.security.SecurityScheme;
8+
import com.sun.net.httpserver.Filter;
9+
import com.sun.net.httpserver.HttpExchange;
10+
import java.io.IOException;
11+
import java.util.LinkedHashMap;
12+
import java.util.List;
13+
import java.util.Map;
14+
import java.util.Optional;
15+
16+
public final class SecurityFilter extends Filter {
17+
18+
private final Map<String, Operation> operationsById;
19+
private final Map<String, SecurityScheme> schemes;
20+
private final List<SecurityRequirement> rootSecurity;
21+
private final Map<String, SchemeValidator> validators;
22+
private final boolean externalAuth;
23+
24+
public SecurityFilter(
25+
Map<String, Operation> operationsById,
26+
Map<String, SecurityScheme> schemes,
27+
List<SecurityRequirement> rootSecurity,
28+
Map<String, SchemeValidator> validators,
29+
boolean externalAuth) {
30+
this.operationsById = Map.copyOf(operationsById);
31+
this.schemes = Map.copyOf(schemes);
32+
this.rootSecurity = List.copyOf(rootSecurity);
33+
this.validators = Map.copyOf(validators);
34+
this.externalAuth = externalAuth;
35+
}
36+
37+
@Override
38+
public String description() {
39+
return "Security";
40+
}
41+
42+
@Override
43+
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
44+
if (externalAuth) {
45+
chain.doFilter(exchange);
46+
return;
47+
}
48+
49+
Request request = DispatchHandler.CURRENT.get();
50+
Operation op = operationsById.get(request.operationId());
51+
List<SecurityRequirement> effective = op.security().orElse(rootSecurity);
52+
53+
if (effective.isEmpty()) {
54+
chain.doFilter(exchange);
55+
return;
56+
}
57+
58+
for (SecurityRequirement group : effective) {
59+
Map<String, Object> principals = trySatisfy(group, exchange, request);
60+
if (principals != null) {
61+
try {
62+
ScopedValue.where(DispatchHandler.CURRENT, request.withPrincipals(principals))
63+
.call(
64+
() -> {
65+
chain.doFilter(exchange);
66+
return null;
67+
});
68+
} catch (IOException | RuntimeException e) {
69+
throw e;
70+
} catch (Exception e) {
71+
throw new IOException(e);
72+
}
73+
return;
74+
}
75+
}
76+
77+
// Rejection rendering is Task 9.
78+
throw new UnsupportedOperationException("rejection path not implemented yet");
79+
}
80+
81+
private Map<String, Object> trySatisfy(
82+
SecurityRequirement group, HttpExchange exchange, Request request) {
83+
Map<String, Object> principals = new LinkedHashMap<>();
84+
for (var entry : group.schemes().entrySet()) {
85+
String schemeName = entry.getKey();
86+
SecurityScheme scheme = schemes.get(schemeName);
87+
ExtractionResult result = CredentialExtractor.extract(scheme, exchange);
88+
if (result.kind() != ExtractionResult.Kind.FOUND) {
89+
return null;
90+
}
91+
Optional<Object> principal =
92+
validators.get(schemeName).validate(request, result.credential());
93+
if (principal.isEmpty()) {
94+
return null;
95+
}
96+
principals.put(schemeName, principal.get());
97+
}
98+
return Map.copyOf(principals);
99+
}
100+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.retailsvc.http.internal;
2+
3+
import com.retailsvc.http.Request;
4+
import java.util.Map;
5+
6+
final class ScopedValueHarness {
7+
private static Map<String, Object> lastSeenPrincipals = Map.of();
8+
9+
static void runWith(Request seed, ThrowingRunnable r) throws Exception {
10+
ScopedValue.where(DispatchHandler.CURRENT, seed)
11+
.call(
12+
() -> {
13+
try {
14+
r.run();
15+
} finally {
16+
lastSeenPrincipals = DispatchHandler.CURRENT.get().principals();
17+
}
18+
return null;
19+
});
20+
}
21+
22+
static Map<String, Object> lastSeenPrincipals() {
23+
return lastSeenPrincipals;
24+
}
25+
26+
interface ThrowingRunnable {
27+
void run() throws Exception;
28+
}
29+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package com.retailsvc.http.internal;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.ArgumentMatchers.any;
5+
import static org.mockito.Mockito.doAnswer;
6+
import static org.mockito.Mockito.mock;
7+
import static org.mockito.Mockito.verify;
8+
import static org.mockito.Mockito.when;
9+
10+
import com.retailsvc.http.Request;
11+
import com.retailsvc.http.SchemeValidator;
12+
import com.retailsvc.http.spec.HttpMethod;
13+
import com.retailsvc.http.spec.Operation;
14+
import com.retailsvc.http.spec.security.SecurityRequirement;
15+
import com.retailsvc.http.spec.security.SecurityScheme;
16+
import com.retailsvc.http.spec.security.SecurityScheme.HttpBearer;
17+
import com.sun.net.httpserver.Filter.Chain;
18+
import com.sun.net.httpserver.Headers;
19+
import com.sun.net.httpserver.HttpExchange;
20+
import java.net.URI;
21+
import java.util.List;
22+
import java.util.Map;
23+
import java.util.Optional;
24+
import java.util.concurrent.atomic.AtomicReference;
25+
import org.junit.jupiter.api.Test;
26+
27+
class SecurityFilterTest {
28+
29+
@Test
30+
void allowsRequestWhenValidatorReturnsPrincipal() throws Exception {
31+
Operation op =
32+
new Operation(
33+
"getX",
34+
HttpMethod.GET,
35+
null,
36+
Optional.empty(),
37+
List.of(),
38+
Map.of(),
39+
Map.of(),
40+
Optional.of(List.of(new SecurityRequirement(Map.of("bearerAuth", List.of())))));
41+
42+
Map<String, SecurityScheme> schemes = Map.of("bearerAuth", new HttpBearer(Optional.empty()));
43+
Map<String, SchemeValidator> validators =
44+
Map.of("bearerAuth", (req, cred) -> Optional.of("user-1"));
45+
46+
SecurityFilter filter =
47+
new SecurityFilter(Map.of("getX", op), schemes, List.of(), validators, false);
48+
49+
HttpExchange ex = mock(HttpExchange.class);
50+
Headers headers = new Headers();
51+
headers.add("Authorization", "Bearer token-xyz");
52+
when(ex.getRequestHeaders()).thenReturn(headers);
53+
when(ex.getRequestURI()).thenReturn(URI.create("http://h/getX"));
54+
55+
AtomicReference<Map<String, Object>> capturedPrincipals = new AtomicReference<>();
56+
Chain chain = mock(Chain.class);
57+
doAnswer(
58+
inv -> {
59+
capturedPrincipals.set(DispatchHandler.CURRENT.get().principals());
60+
return null;
61+
})
62+
.when(chain)
63+
.doFilter(any());
64+
65+
Request req = newMinimalRequest("getX");
66+
ScopedValueHarness.runWith(req, () -> filter.doFilter(ex, chain));
67+
68+
verify(chain).doFilter(ex);
69+
assertThat(capturedPrincipals.get()).containsEntry("bearerAuth", "user-1");
70+
}
71+
72+
@Test
73+
void passesThroughWhenOperationHasNoSecurity() throws Exception {
74+
Operation op =
75+
new Operation(
76+
"getY",
77+
HttpMethod.GET,
78+
null,
79+
Optional.empty(),
80+
List.of(),
81+
Map.of(),
82+
Map.of(),
83+
Optional.empty()); // inherits root, root is empty
84+
85+
SecurityFilter filter =
86+
new SecurityFilter(Map.of("getY", op), Map.of(), List.of(), Map.of(), false);
87+
88+
HttpExchange ex = mock(HttpExchange.class);
89+
Chain chain = mock(Chain.class);
90+
ScopedValueHarness.runWith(newMinimalRequest("getY"), () -> filter.doFilter(ex, chain));
91+
92+
verify(chain).doFilter(ex);
93+
assertThat(ScopedValueHarness.lastSeenPrincipals()).isEmpty();
94+
}
95+
96+
private static Request newMinimalRequest(String operationId) {
97+
return new Request(new byte[0], null, null, operationId, Map.of(), null, h -> null);
98+
}
99+
}

0 commit comments

Comments
 (0)