Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions bin/configs/jaxrs-spec-quarkus-security.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
generatorName: jaxrs-spec
outputDir: samples/server/petstore/jaxrs-spec/quarkus-security
library: quarkus
inputSpec: modules/openapi-generator/src/test/resources/3_0/jaxrs-spec/quarkus-mixed-security.yaml
templateDir: modules/openapi-generator/src/main/resources/JavaJaxRS/spec
additionalProperties:
artifactId: jaxrs-spec-quarkus-security
hideGenerationTimestamp: "true"
useJakartaEe: "true"
useJakartaSecurityAnnotations: "true"
interfaceOnly: "true"
2 changes: 1 addition & 1 deletion docs/generators/jaxrs-cxf-cdi.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,10 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|title|a title describing the application| |OpenAPI Server|
|useBeanValidation|Use BeanValidation API annotations| |true|
|useJakartaEe|whether to use Jakarta EE namespace instead of javax| |false|
|useJakartaSecurityAnnotations|Whether to generate Jakarta security annotations (@RolesAllowed, @PermitAll). Requires useJakartaEe=true. Currently only supported when library is set to quarkus.| |false|
|useMicroProfileOpenAPIAnnotations|Whether to generate Microprofile OpenAPI annotations. Only valid when library is set to quarkus.| |false|
|useMutiny|Whether to use Smallrye Mutiny instead of CompletionStage for asynchronous computation. Only valid when library is set to quarkus.| |false|
|useOneOfInterfaces|whether to use a java interface to describe a set of oneOf options, where each option is a class that implements the interface| |false|
|useQuarkusSecurityAnnotations|Whether to generate Quarkus security annotations (@Authenticated, @RolesAllowed, @PermitAll). Only valid when library is set to quarkus.| |false|
|useSwaggerAnnotations|Whether to generate Swagger annotations.| |true|
|useSwaggerV3Annotations|Whether to generate Swagger v3 (OpenAPI v3) annotations.| |false|
|useTags|use tags for creating interface and controller classnames| |false|
Expand Down
2 changes: 1 addition & 1 deletion docs/generators/jaxrs-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,10 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|title|a title describing the application| |OpenAPI Server|
|useBeanValidation|Use BeanValidation API annotations| |true|
|useJakartaEe|whether to use Jakarta EE namespace instead of javax| |false|
|useJakartaSecurityAnnotations|Whether to generate Jakarta security annotations (@RolesAllowed, @PermitAll). Requires useJakartaEe=true. Currently only supported when library is set to quarkus.| |false|
|useMicroProfileOpenAPIAnnotations|Whether to generate Microprofile OpenAPI annotations. Only valid when library is set to quarkus.| |false|
|useMutiny|Whether to use Smallrye Mutiny instead of CompletionStage for asynchronous computation. Only valid when library is set to quarkus.| |false|
|useOneOfInterfaces|whether to use a java interface to describe a set of oneOf options, where each option is a class that implements the interface| |false|
|useQuarkusSecurityAnnotations|Whether to generate Quarkus security annotations (@Authenticated, @RolesAllowed, @PermitAll). Only valid when library is set to quarkus.| |false|
|useSwaggerAnnotations|Whether to generate Swagger annotations.| |true|
|useSwaggerV3Annotations|Whether to generate Swagger v3 (OpenAPI v3) annotations.| |false|
|useTags|use tags for creating interface and controller classnames| |false|
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
/*
* Copyright 2018 OpenAPI-Generator Contributors (https://openapi-generator.tech)
* Copyright 2018 SmartBear Software
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.openapitools.codegen.languages;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import org.openapitools.codegen.CodegenOperation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Translates an OpenAPI operation's security requirements into Jakarta security
* vendor extensions on a {@link CodegenOperation} for downstream Mustache templates.
*
* <p>The OpenAPI {@code security} array uses OR semantics (any one alternative
* satisfies the request); the Jakarta annotations are AND-stacked. The two cannot
* always be reconciled, so this class emits the least restrictive annotation that
* is still correct for the OR group.
*
* <p>A single vendor extension {@code x-jakarta-roles-allowed} carries the value to
* emit:
* <ul>
* <li>{@code ["**"]} for the any-authenticated-user case, producing
* {@code @RolesAllowed({"**"})}.
* <li>A sorted, deduplicated list of scope names (e.g. {@code ["admin", "user"]})
* when every OR alternative is scoped, producing
* {@code @RolesAllowed({"admin","user"})}.
* <li>Unset when the operation does not qualify (anonymous OR alternative,
* mixed-scope AND group, etc.).
* </ul>
*
* <p>The wildcard and scoped emissions are mutually exclusive per operation: if any
* OR alternative qualifies as "any authenticated user", the wildcard wins and the
* scoped path is skipped.
*/
final class JakartaSecurityAnnotationProcessor {

static final String VENDOR_X_JAKARTA_ROLES_ALLOWED = "x-jakarta-roles-allowed";

private static final List<String> ANY_AUTHENTICATED_ROLE = Collections.singletonList("**");

private final Logger LOGGER = LoggerFactory.getLogger(JakartaSecurityAnnotationProcessor.class);

/**
* Inspects {@code rawOp}'s security requirements (falling back to the global
* {@code openAPI.security} when the operation does not override) and sets
* {@code x-jakarta-roles-allowed} on {@code op} when the operation qualifies
* for {@code @RolesAllowed} emission.
*/
void applyTo(CodegenOperation op, Operation rawOp, OpenAPI openAPI) {
// Use the raw Operation here rather than op.authMethods: by the time postProcessOperationsWithModels
// runs, DefaultGenerator.filterAuthMethods has flattened all SecurityRequirements into a plain list,
// losing the AND-group structure needed to evaluate mixed-scope combinations correctly.
List<SecurityRequirement> requirements = rawOp.getSecurity();
if (requirements == null) {
// Fall back to the global security block when the operation does not override it.
requirements = openAPI.getSecurity();
}
Map<String, SecurityScheme> schemes = resolveSchemes(openAPI);

if (qualifiesForAnyRoles(requirements, schemes)) {
op.vendorExtensions.put(VENDOR_X_JAKARTA_ROLES_ALLOWED, ANY_AUTHENTICATED_ROLE);
return; // mutually exclusive -- short-circuit before the scoped path runs
}
List<String> scopes = collectRolesAllowedScopes(requirements, schemes);
if (scopes != null && !scopes.isEmpty()) {
op.vendorExtensions.put(VENDOR_X_JAKARTA_ROLES_ALLOWED, scopes);
}
}

/**
* Returns true when at least one OR alternative fully qualifies for
* {@code @RolesAllowed({"**"})} and no alternative is anonymous ({@code - {}}).
*
* <p>An empty {@link SecurityRequirement} ({@code - {}}) inside the OR list means
* the operation may also be called unauthenticated. When that is present, the
* least-restrictive alternative is "no auth required", so emitting
* {@code @RolesAllowed({"**"})} would force authentication and contradict the
* spec -- we return false instead and let the future {@code @PermitAll} branch
* handle that case.
*/
private boolean qualifiesForAnyRoles(List<SecurityRequirement> requirements,
Map<String, SecurityScheme> schemes) {
if (requirements == null || requirements.isEmpty()) {
return false;
}
boolean anyQualifies = false;
for (SecurityRequirement requirement : requirements) {
if (requirement.isEmpty()) {
// Anonymous OR alternative -- least restrictive wins; do not emit @RolesAllowed.
return false;
}
if (andGroupQualifies(requirement, schemes)) {
anyQualifies = true;
}
}
return anyQualifies;
}

/**
* A single {@link SecurityRequirement} is an AND group: all schemes must be
* satisfied simultaneously. If any scheme in the group has explicit scopes
* (e.g. {@code oauth2: [admin:write]}), the combined requirement is more
* restrictive than "any authenticated user" and does not qualify.
*/
private boolean andGroupQualifies(SecurityRequirement requirement, Map<String, SecurityScheme> schemes) {
for (Map.Entry<String, List<String>> entry : requirement.entrySet()) {
SecurityScheme scheme = schemes.get(entry.getKey());
if (scheme == null) {
LOGGER.warn("Security requirement references undefined scheme '{}' -- skipping Jakarta security annotation for this AND group.",
entry.getKey());
return false;
}
if (!schemeQualifies(scheme, entry.getValue())) {
return false;
}
}
return true;
}

private boolean schemeQualifies(SecurityScheme scheme, List<String> scopes) {
if (scheme.getType() == null) {
LOGGER.warn("Security scheme is missing 'type' -- skipping Jakarta security annotation.");
return false;
}
switch (scheme.getType()) {
case OAUTH2:
case OPENIDCONNECT:
// Empty scope list means the operation requires authentication but no specific role,
// so @RolesAllowed({"**"}) is correct. Non-empty scopes are handled by collectRolesAllowedScopes.
return scopes == null || scopes.isEmpty();
case HTTP:
case APIKEY:
case MUTUALTLS:
// These schemes have no scope concept; any valid credential satisfies them.
return true;
default:
LOGGER.warn("Unrecognised security scheme type '{}' -- skipping Jakarta security annotation.",
scheme.getType());
return false;
}
}

/**
* Returns the deduplicated, alphabetically sorted union of scope names across every OR
* alternative, or {@code null} if the requirement set does not qualify (anonymous OR
* alternative, mixed-scope AND group, undefined scheme, or no requirements at all).
*
* <p>A {@code null} return means the scoped {@code @RolesAllowed} annotation must not
* be emitted for this operation.
*/
private List<String> collectRolesAllowedScopes(List<SecurityRequirement> requirements,
Map<String, SecurityScheme> schemes) {
if (requirements == null || requirements.isEmpty()) {
return null;
}
Set<String> union = new TreeSet<>(); // sorted, deduplicated
for (SecurityRequirement requirement : requirements) {
if (requirement.isEmpty()) {
// Anonymous OR alternative -- defer to @PermitAll (future PR).
return null;
}
List<String> groupScopes = collectAndGroupScopes(requirement, schemes);
if (groupScopes == null) {
// Unscopable AND group -- bail the entire operation.
return null;
}
union.addAll(groupScopes);
}
return new ArrayList<>(union);
}

/**
* Returns the scope list contributed by a single AND group, or {@code null} if the AND
* group cannot be expressed as a single Jakarta {@code @RolesAllowed} annotation.
*
* <p>At most ONE scheme in the AND group may have non-empty scopes (the "scoped scheme").
* If two or more schemes carry competing scope sets, Quarkus annotations cannot express
* the AND-of-different-scope-sets relationship -- we log a warning and return {@code null}.
*
* <p>An empty list (not {@code null}) is returned when the AND group is valid but no
* scheme contributes scopes; the caller treats that as "no scopes from this alternative".
*/
private List<String> collectAndGroupScopes(SecurityRequirement requirement,
Map<String, SecurityScheme> schemes) {
List<String> scopedSchemeScopes = null;
int scopedSchemeCount = 0;
for (Map.Entry<String, List<String>> entry : requirement.entrySet()) {
SecurityScheme scheme = schemes.get(entry.getKey());
if (scheme == null) {
LOGGER.warn("Security requirement references undefined scheme '{}' -- skipping Jakarta scoped @RolesAllowed for this operation.",
entry.getKey());
return null;
}
if (scheme.getType() == null) {
LOGGER.warn("Security scheme '{}' is missing 'type' -- skipping Jakarta scoped @RolesAllowed.",
entry.getKey());
return null;
}
switch (scheme.getType()) {
case OAUTH2:
case OPENIDCONNECT:
List<String> scopes = entry.getValue();
if (scopes != null && !scopes.isEmpty()) {
scopedSchemeCount++;
if (scopedSchemeCount > 1) {
LOGGER.warn(
"AND-group contains multiple scoped schemes (e.g. '{}'); Jakarta @RolesAllowed cannot express AND of different scope sets -- skipping scoped @RolesAllowed for this operation.",
entry.getKey());
return null;
}
scopedSchemeScopes = scopes;
}
// Unscoped OAuth2/OIDC contributes nothing to the scope list.
break;
case HTTP:
case APIKEY:
case MUTUALTLS:
// No scope concept; participates in the AND group but contributes no scopes.
break;
default:
LOGGER.warn("Unrecognised security scheme type '{}' -- skipping Jakarta scoped @RolesAllowed.",
scheme.getType());
return null;
}
}
return scopedSchemeScopes != null ? scopedSchemeScopes : Collections.emptyList();
}

private static Map<String, SecurityScheme> resolveSchemes(OpenAPI openAPI) {
if (openAPI.getComponents() != null && openAPI.getComponents().getSecuritySchemes() != null) {
return openAPI.getComponents().getSecuritySchemes();
}
return Collections.emptyMap();
}
}
Loading