From d2d9fc224f1e9eef2cdb0a94c11e790d67ad0249 Mon Sep 17 00:00:00 2001 From: Joe Mahady Date: Tue, 2 Dec 2025 15:18:26 +0000 Subject: [PATCH 1/9] [TNZ-70080]: Testing OpenAPI document endpoints --- dependencies.gradle | 1 + server/build.gradle | 3 + ...SpringServletXmlSecurityConfiguration.java | 8 +- .../scim/endpoints/ScimGroupEndpoints.java | 136 +++++++++++++++++- .../uaa/scim/endpoints/ScimUserEndpoints.java | 52 +++++++ uaa/build.gradle | 3 + .../identity/uaa/OpenApiConfiguration.java | 87 +++++++++++ .../main/resources/application-openapi.yml | 21 +++ 8 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java create mode 100644 uaa/src/main/resources/application-openapi.yml diff --git a/dependencies.gradle b/dependencies.gradle index 56751895ca4..80e34a0073e 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -84,6 +84,7 @@ libraries.springContextSupport = "org.springframework:spring-context-support" libraries.springJdbc = "org.springframework:spring-jdbc" libraries.springLdapCore = "org.springframework.ldap:spring-ldap-core" libraries.springRestdocs = "org.springframework.restdocs:spring-restdocs-mockmvc" +libraries.springdocOpenapi = "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0" libraries.springRetry = "org.springframework.retry:spring-retry" libraries.springSecurityConfig = "org.springframework.security:spring-security-config" libraries.springSecurityCore = "org.springframework.security:spring-security-core" diff --git a/server/build.gradle b/server/build.gradle index cf8cc62ea47..3cbc37f72c0 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -35,6 +35,9 @@ dependencies { implementation(libraries.guava) + // OpenAPI documentation + implementation(libraries.springdocOpenapi) + implementation(libraries.aspectJRt) implementation(libraries.aspectJWeaver) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlSecurityConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlSecurityConfiguration.java index a96362edd8a..1bcbace9f02 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlSecurityConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlSecurityConfiguration.java @@ -64,7 +64,13 @@ public class SpringServletXmlSecurityConfiguration { "/session", "/session_management", "/oauth/token/.well-known/openid-configuration", - "/.well-known/openid-configuration" + "/.well-known/openid-configuration", + // OpenAPI documentation endpoints + "/v3/api-docs/**", + "/v3/api-docs", + "/v3/api-docs.yaml", + "/swagger-ui/**", + "/swagger-ui.html" }; private final String[] secFilterOpenSamlEndPoints = { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java index b1e61d14bee..c0ec6096e37 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java @@ -1,6 +1,15 @@ package org.cloudfoundry.identity.uaa.scim.endpoints; import com.jayway.jsonpath.JsonPathException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; import org.cloudfoundry.identity.uaa.resources.AttributeNameMapper; import org.cloudfoundry.identity.uaa.resources.SearchResults; import org.cloudfoundry.identity.uaa.resources.SearchResultsFactory; @@ -65,6 +74,7 @@ import static org.springframework.util.StringUtils.hasText; @Controller +@Tag(name = "Groups", description = "SCIM Group management for admin roles") public class ScimGroupEndpoints { private static final String E_TAG = "ETag"; @@ -140,12 +150,43 @@ private List filterForCurrentUser(List input, int startInd @GetMapping({"/Groups", "/Groups/"}) @ResponseBody + @Operation( + summary = "List Groups", + description = "Query for groups with optional filtering, sorting, and pagination. Used to find existing admin groups like 'cloud_controller.admin'.", + security = @SecurityRequirement(name = "bearerAuth", scopes = {"scim.read"}) + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Groups retrieved successfully", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "totalResults": 1, + "startIndex": 1, + "itemsPerPage": 1, + "schemas": ["urn:scim:schemas:core:1.0"], + "resources": [{ + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "displayName": "cloud_controller.admin", + "description": "Cloud Controller Administrators" + }] + } + """))), + @ApiResponse(responseCode = "400", description = "Bad Request - Invalid filter expression"), + @ApiResponse(responseCode = "401", description = "Unauthorized - Invalid or missing authentication token"), + @ApiResponse(responseCode = "403", description = "Forbidden - Insufficient privileges") + }) public SearchResults listGroups( + @Parameter(description = "Comma-separated list of attributes to return", example = "id,displayName,members") @RequestParam(value = "attributes", required = false) String attributesCommaSeparated, + @Parameter(description = "SCIM filter expression for searching groups", example = "displayName eq \"cloud_controller.admin\"") @RequestParam(required = false, defaultValue = "id pr") String filter, + @Parameter(description = "Field to sort by", schema = @Schema(allowableValues = {"created", "displayName", "lastModified"})) @RequestParam(required = false, defaultValue = "created") String sortBy, + @Parameter(description = "Sort order", schema = @Schema(allowableValues = {"ascending", "descending"})) @RequestParam(required = false, defaultValue = "ascending") String sortOrder, + @Parameter(description = "1-based index of first result", schema = @Schema(minimum = "1")) @RequestParam(required = false, defaultValue = "1") int startIndex, + @Parameter(description = "Maximum number of results to return", schema = @Schema(minimum = "1", maximum = "500")) @RequestParam(required = false, defaultValue = "100") int count) { if (count > groupMaxCount) { @@ -369,7 +410,41 @@ public ScimGroup getGroup(@PathVariable String groupId, HttpServletResponse http @PostMapping({"/Groups", "/Groups/"}) @ResponseStatus(HttpStatus.CREATED) @ResponseBody - public ScimGroup createGroup(@RequestBody ScimGroup group, HttpServletResponse httpServletResponse) { + @Operation( + summary = "Create Group", + description = "Create a new group (admin scope). Used to create admin groups like 'cloud_controller.admin' if they don't exist.", + security = @SecurityRequirement(name = "bearerAuth", scopes = {"scim.write"}) + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Group created successfully", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "displayName": "cloud_controller.admin", + "description": "Cloud Controller Administrators", + "schemas": ["urn:scim:schemas:core:1.0"], + "meta": { + "version": 0, + "created": "2023-11-17T10:00:00.000Z", + "lastModified": "2023-11-17T10:00:00.000Z" + } + } + """))), + @ApiResponse(responseCode = "400", description = "Bad Request - Invalid request syntax"), + @ApiResponse(responseCode = "401", description = "Unauthorized - Invalid or missing authentication token"), + @ApiResponse(responseCode = "403", description = "Forbidden - Insufficient privileges"), + @ApiResponse(responseCode = "409", description = "Conflict - Group already exists") + }) + public ScimGroup createGroup( + @Parameter(description = "Group to create", required = true, + content = @Content(examples = @ExampleObject(value = """ + { + "displayName": "cloud_controller.admin", + "description": "Cloud Controller Administrators" + } + """))) + @RequestBody ScimGroup group, HttpServletResponse httpServletResponse) { group.setZoneId(identityZoneManager.getCurrentIdentityZoneId()); ScimGroup created = dao.create(group, identityZoneManager.getCurrentIdentityZoneId()); if (group.getMembers() != null) { @@ -565,7 +640,39 @@ public ResponseEntity> listGroupMemberships(@PathVariable @PostMapping({"/Groups/{groupId}/members", "/Groups/{groupId}/members/"}) @ResponseStatus(HttpStatus.CREATED) @ResponseBody - public ScimGroupMember addMemberToGroup(@PathVariable String groupId, @RequestBody ScimGroupMember member) { + @Operation( + summary = "Add Member to Group", + description = "Add a user to a group, effectively assigning an admin role. This is the key operation for granting admin privileges to users.", + security = @SecurityRequirement(name = "bearerAuth", scopes = {"scim.write", "groups.update"}) + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Member added successfully", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "value": "3ebe4bda-74a2-40c4-8b70-f771d9bc8b9f", + "type": "USER", + "origin": "uaa" + } + """))), + @ApiResponse(responseCode = "400", description = "Bad Request - Invalid request"), + @ApiResponse(responseCode = "401", description = "Unauthorized - Invalid or missing authentication token"), + @ApiResponse(responseCode = "403", description = "Forbidden - Insufficient privileges"), + @ApiResponse(responseCode = "404", description = "Not Found - Group or user not found"), + @ApiResponse(responseCode = "409", description = "Conflict - Member already exists in group") + }) + public ScimGroupMember addMemberToGroup( + @Parameter(description = "UUID of the group", required = true, example = "f47ac10b-58cc-4372-a567-0e02b2c3d479") + @PathVariable String groupId, + @Parameter(description = "Member to add to the group", required = true, + content = @Content(examples = @ExampleObject(value = """ + { + "value": "3ebe4bda-74a2-40c4-8b70-f771d9bc8b9f", + "type": "USER", + "origin": "uaa" + } + """))) + @RequestBody ScimGroupMember member) { return membershipManager.addMember(groupId, member, identityZoneManager.getCurrentIdentityZoneId()); } @@ -573,7 +680,30 @@ public ScimGroupMember addMemberToGroup(@PathVariable String groupId, @RequestBo @DeleteMapping({"/Groups/{groupId}/members/{memberId}", "/Groups/{groupId}/members/{memberId}/"}) @ResponseBody @ResponseStatus(HttpStatus.OK) - public ScimGroupMember deleteGroupMembership(@PathVariable String groupId, @PathVariable String memberId) { + @Operation( + summary = "Remove Member from Group", + description = "Remove a user from a group, effectively revoking an admin role. This is used to revoke admin privileges from users.", + security = @SecurityRequirement(name = "bearerAuth", scopes = {"scim.write", "groups.update"}) + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Member removed successfully", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "value": "3ebe4bda-74a2-40c4-8b70-f771d9bc8b9f", + "type": "USER", + "origin": "uaa" + } + """))), + @ApiResponse(responseCode = "401", description = "Unauthorized - Invalid or missing authentication token"), + @ApiResponse(responseCode = "403", description = "Forbidden - Insufficient privileges"), + @ApiResponse(responseCode = "404", description = "Not Found - Group or member not found") + }) + public ScimGroupMember deleteGroupMembership( + @Parameter(description = "UUID of the group", required = true, example = "f47ac10b-58cc-4372-a567-0e02b2c3d479") + @PathVariable String groupId, + @Parameter(description = "UUID of the member to remove", required = true, example = "3ebe4bda-74a2-40c4-8b70-f771d9bc8b9f") + @PathVariable String memberId) { return membershipManager.removeMemberById(groupId, memberId, identityZoneManager.getCurrentIdentityZoneId()); } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java index 7e652b0b452..a59ad9b8912 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java @@ -1,6 +1,15 @@ package org.cloudfoundry.identity.uaa.scim.endpoints; import com.jayway.jsonpath.JsonPathException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.Getter; import org.cloudfoundry.identity.uaa.account.UserAccountStatus; import org.cloudfoundry.identity.uaa.account.event.UserAccountUnlockedEvent; @@ -117,6 +126,7 @@ objectName = "cloudfoundry.identity:name=UserEndpoint", description = "UAA User API Metrics" ) +@Tag(name = "Users", description = "SCIM User queries for admin role assignment") public class ScimUserEndpoints implements InitializingBean, ApplicationEventPublisherAware { private static final Logger logger = LoggerFactory.getLogger(ScimUserEndpoints.class); @@ -491,12 +501,54 @@ private int getVersion(String userId, String etag) { @GetMapping({"/Users", "/Users/"}) @ResponseBody + @Operation( + summary = "List/Filter Users", + description = "Query for users with optional filtering, sorting, and pagination. Used to find users to assign admin roles to.", + security = @SecurityRequirement(name = "bearerAuth", scopes = {"scim.read"}) + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Users retrieved successfully", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "totalResults": 1, + "startIndex": 1, + "itemsPerPage": 1, + "schemas": ["urn:scim:schemas:core:1.0"], + "resources": [{ + "id": "3ebe4bda-74a2-40c4-8b70-f771d9bc8b9f", + "userName": "admin", + "emails": [{"value": "admin@example.com", "primary": true}], + "groups": [{ + "value": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "display": "cloud_controller.admin", + "type": "DIRECT" + }] + }] + } + """))), + @ApiResponse(responseCode = "400", description = "Bad Request - Invalid filter expression"), + @ApiResponse(responseCode = "401", description = "Unauthorized - Invalid or missing authentication token"), + @ApiResponse(responseCode = "403", description = "Forbidden - Insufficient privileges") + }) public SearchResults findUsers( + @Parameter(description = "Comma-separated list of attributes to return", example = "id,userName,emails,groups") @RequestParam(value = "attributes", required = false) String attributesCommaSeparated, + @Parameter(description = "SCIM filter expression for searching users", + examples = { + @ExampleObject(name = "By username", value = "userName eq \"admin\""), + @ExampleObject(name = "By email", value = "emails.value eq \"admin@example.com\""), + @ExampleObject(name = "Active users", value = "active eq true"), + @ExampleObject(name = "Users with admin groups", value = "groups.display co \"admin\"") + }) @RequestParam(required = false, defaultValue = "id pr") String filter, + @Parameter(description = "Field to sort by", schema = @Schema(allowableValues = {"created", "userName", "email", "lastModified"})) @RequestParam(required = false, defaultValue = "created") String sortBy, + @Parameter(description = "Sort order", schema = @Schema(allowableValues = {"ascending", "descending"})) @RequestParam(required = false, defaultValue = "ascending") String sortOrder, + @Parameter(description = "1-based index of first result", schema = @Schema(minimum = "1")) @RequestParam(required = false, defaultValue = "1") int startIndex, + @Parameter(description = "Maximum number of results to return", schema = @Schema(minimum = "1", maximum = "500")) @RequestParam(required = false, defaultValue = "100") int count) { if (startIndex < 1) { diff --git a/uaa/build.gradle b/uaa/build.gradle index c762d862acf..703d3a24375 100644 --- a/uaa/build.gradle +++ b/uaa/build.gradle @@ -58,6 +58,9 @@ dependencies { implementation(libraries.braveInstrumentation) implementation(libraries.braveContextSlf4j) + + // OpenAPI documentation + implementation(libraries.springdocOpenapi) implementation(libraries.springWeb) implementation(libraries.springWebMvc) diff --git a/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java b/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java new file mode 100644 index 00000000000..666fd9c8526 --- /dev/null +++ b/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java @@ -0,0 +1,87 @@ +package org.cloudfoundry.identity.uaa; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springdoc.core.models.GroupedOpenApi; +import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * OpenAPI 3.0 configuration for UAA SCIM API documentation. + * + * This configuration provides interactive API documentation for UAA SCIM endpoints, + * specifically focused on admin role management capabilities. + */ +@Configuration +public class OpenApiConfiguration { + + @Bean + public OpenAPI uaaOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("UAA SCIM 2.0 API") + .description(""" + UAA SCIM 2.0 API endpoints for managing admin roles and user groups. + + This API provides endpoints for: + - Creating and managing groups (admin scopes) + - Adding/removing users from groups (assigning admin roles) + - Querying users and groups + + Based on SCIM 2.0 specification and UAA implementation. + """) + .version("1.0.0") + .contact(new Contact() + .name("UAA Team") + .url("https://github.com/cloudfoundry/uaa")) + .license(new License() + .name("Apache 2.0") + .url("https://www.apache.org/licenses/LICENSE-2.0"))) + .servers(List.of( + new Server() + .url("http://localhost:8080/uaa") + .description("Local Development"), + new Server() + .url("https://uaa.example.com") + .description("UAA Server") + )) + .addSecurityItem(new SecurityRequirement().addList("bearerAuth")) + .components(new Components() + .addSecuritySchemes("bearerAuth", new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description(""" + OAuth2 Bearer token with required scopes: + - scim.read: Read access to users and groups + - scim.write: Full access to create/update users and groups + - groups.update: Update group memberships + """))); + } + + @Bean + public GroupedOpenApi scimApi() { + return GroupedOpenApi.builder() + .group("scim") + .pathsToMatch("/Groups/**", "/Users/**") + .packagesToScan("org.cloudfoundry.identity.uaa.scim.endpoints") + .build(); + } + + @Bean + public SpringDocConfigProperties springDocConfigProperties() { + SpringDocConfigProperties properties = new SpringDocConfigProperties(); + // Enable YAML format + properties.setWriterWithDefaultPrettyPrinter(true); + return properties; + } +} diff --git a/uaa/src/main/resources/application-openapi.yml b/uaa/src/main/resources/application-openapi.yml new file mode 100644 index 00000000000..064b6c2090d --- /dev/null +++ b/uaa/src/main/resources/application-openapi.yml @@ -0,0 +1,21 @@ +springdoc: + api-docs: + enabled: true + path: /v3/api-docs + # Enable YAML format + resolve-schema-properties: true + swagger-ui: + enabled: true + path: /swagger-ui.html + show-actuator: false + default-consumes-media-type: application/json + default-produces-media-type: application/json + packages-to-scan: org.cloudfoundry.identity.uaa.scim.endpoints + paths-to-match: /Groups/**, /Users/** + disable-swagger-default-url: true + use-management-port: false + # Enable YAML format + writer-with-default-pretty-printer: true + # Disable problematic features + model-and-view-allowed: false + override-with-generic-response: false From f0b28f0a7c1ed6f2ac0b14fada3ec7e908ba216d Mon Sep 17 00:00:00 2001 From: Joe Mahady Date: Wed, 28 Jan 2026 13:45:40 +0000 Subject: [PATCH 2/9] [TNZ-70080]: refactored openapi spec generation --- scripts/boot/uaa.yml | 20 +++++++ .../identity/uaa/OpenApiConfiguration.java | 54 ++++++++----------- .../main/resources/application-openapi.yml | 21 -------- uaa/src/main/resources/uaa.yml | 22 +++++++- 4 files changed, 63 insertions(+), 54 deletions(-) delete mode 100644 uaa/src/main/resources/application-openapi.yml diff --git a/scripts/boot/uaa.yml b/scripts/boot/uaa.yml index bbbcd7c08b4..317005f163d 100644 --- a/scripts/boot/uaa.yml +++ b/scripts/boot/uaa.yml @@ -365,3 +365,23 @@ oauth: - roles - user_attributes - uaa.offline_token + +springdoc: + api-docs: + enabled: true + path: /v3/api-docs + resolve-schema-properties: true + swagger-ui: + enabled: true + path: /swagger-ui.html + url: /v3/api-docs + disable-swagger-default-url: true + try-it-out-enabled: true + show-actuator: false + default-consumes-media-type: application/json + default-produces-media-type: application/json + disable-swagger-default-url: true + use-management-port: false + writer-with-default-pretty-printer: false + model-and-view-allowed: false + override-with-generic-response: false \ No newline at end of file diff --git a/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java b/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java index 666fd9c8526..7a949436cb9 100644 --- a/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java +++ b/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java @@ -8,8 +8,6 @@ import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; -import org.springdoc.core.models.GroupedOpenApi; -import org.springdoc.core.properties.SpringDocConfigProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -28,16 +26,18 @@ public class OpenApiConfiguration { public OpenAPI uaaOpenAPI() { return new OpenAPI() .info(new Info() - .title("UAA SCIM 2.0 API") + .title("UAA API Reference") .description(""" - UAA SCIM 2.0 API endpoints for managing admin roles and user groups. - - This API provides endpoints for: - - Creating and managing groups (admin scopes) - - Adding/removing users from groups (assigning admin roles) - - Querying users and groups - - Based on SCIM 2.0 specification and UAA implementation. + UAA (User Account and Authentication) is an OAuth2/OpenID Connect server + for centralized identity management. This API reference provides endpoints + for managing users, groups, and client applications. + + Key Features: + - OAuth2 & OpenID Connect authentication + - SCIM 2.0 user and group management + - Identity provider integration (SAML, LDAP, OIDC) + - Multi-tenancy via identity zones + - Client application management """) .version("1.0.0") .contact(new Contact() @@ -52,7 +52,7 @@ public OpenAPI uaaOpenAPI() { .description("Local Development"), new Server() .url("https://uaa.example.com") - .description("UAA Server") + .description("Production UAA Server") )) .addSecurityItem(new SecurityRequirement().addList("bearerAuth")) .components(new Components() @@ -61,27 +61,17 @@ public OpenAPI uaaOpenAPI() { .scheme("bearer") .bearerFormat("JWT") .description(""" - OAuth2 Bearer token with required scopes: - - scim.read: Read access to users and groups - - scim.write: Full access to create/update users and groups - - groups.update: Update group memberships + OAuth2 Bearer token (JWT format). + + Required scopes vary by endpoint: + - OAuth/Token: uaa.admin, clients.admin + - Users/Groups: scim.read, scim.write, groups.update + - Clients: clients.read, clients.write, clients.admin + - Identity Zones: zones.read, zones.write, uaa.admin + - Identity Providers: idps.read, idps.write + + Obtain tokens via /oauth/token endpoint. """))); } - @Bean - public GroupedOpenApi scimApi() { - return GroupedOpenApi.builder() - .group("scim") - .pathsToMatch("/Groups/**", "/Users/**") - .packagesToScan("org.cloudfoundry.identity.uaa.scim.endpoints") - .build(); - } - - @Bean - public SpringDocConfigProperties springDocConfigProperties() { - SpringDocConfigProperties properties = new SpringDocConfigProperties(); - // Enable YAML format - properties.setWriterWithDefaultPrettyPrinter(true); - return properties; - } } diff --git a/uaa/src/main/resources/application-openapi.yml b/uaa/src/main/resources/application-openapi.yml deleted file mode 100644 index 064b6c2090d..00000000000 --- a/uaa/src/main/resources/application-openapi.yml +++ /dev/null @@ -1,21 +0,0 @@ -springdoc: - api-docs: - enabled: true - path: /v3/api-docs - # Enable YAML format - resolve-schema-properties: true - swagger-ui: - enabled: true - path: /swagger-ui.html - show-actuator: false - default-consumes-media-type: application/json - default-produces-media-type: application/json - packages-to-scan: org.cloudfoundry.identity.uaa.scim.endpoints - paths-to-match: /Groups/**, /Users/** - disable-swagger-default-url: true - use-management-port: false - # Enable YAML format - writer-with-default-pretty-printer: true - # Disable problematic features - model-and-view-allowed: false - override-with-generic-response: false diff --git a/uaa/src/main/resources/uaa.yml b/uaa/src/main/resources/uaa.yml index 3f47ed49da0..17719105cec 100755 --- a/uaa/src/main/resources/uaa.yml +++ b/uaa/src/main/resources/uaa.yml @@ -590,4 +590,24 @@ ldap: maxSearchDepth: 10 autoAdd: true externalGroupsWhitelist: - - '*' \ No newline at end of file + - '*' + +springdoc: + api-docs: + enabled: true + path: /v3/api-docs + resolve-schema-properties: true + swagger-ui: + enabled: true + path: /swagger-ui.html + url: /v3/api-docs + disable-swagger-default-url: true + try-it-out-enabled: true + show-actuator: false + default-consumes-media-type: application/json + default-produces-media-type: application/json + disable-swagger-default-url: true + use-management-port: false + writer-with-default-pretty-printer: false + model-and-view-allowed: false + override-with-generic-response: false \ No newline at end of file From 910bf1bd2a75f945cf71c3ab8024db6abbcb327a Mon Sep 17 00:00:00 2001 From: Joe Mahady Date: Fri, 30 Jan 2026 12:57:41 +0000 Subject: [PATCH 3/9] [TNZ-70080]: refactored openapi spec generation --- scripts/boot/uaa.yml | 2 + .../scim/endpoints/ScimGroupEndpoints.java | 128 +----------------- .../uaa/scim/endpoints/ScimUserEndpoints.java | 43 ------ .../identity/uaa/OpenApiConfiguration.java | 17 ++- uaa/src/main/resources/uaa.yml | 2 + 5 files changed, 19 insertions(+), 173 deletions(-) diff --git a/scripts/boot/uaa.yml b/scripts/boot/uaa.yml index 317005f163d..32c2e9a2c31 100644 --- a/scripts/boot/uaa.yml +++ b/scripts/boot/uaa.yml @@ -377,6 +377,8 @@ springdoc: url: /v3/api-docs disable-swagger-default-url: true try-it-out-enabled: true + tags-sorter: alpha + operations-sorter: alpha show-actuator: false default-consumes-media-type: application/json default-produces-media-type: application/json diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java index c0ec6096e37..24f92bee2c8 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java @@ -74,7 +74,6 @@ import static org.springframework.util.StringUtils.hasText; @Controller -@Tag(name = "Groups", description = "SCIM Group management for admin roles") public class ScimGroupEndpoints { private static final String E_TAG = "ETag"; @@ -150,43 +149,12 @@ private List filterForCurrentUser(List input, int startInd @GetMapping({"/Groups", "/Groups/"}) @ResponseBody - @Operation( - summary = "List Groups", - description = "Query for groups with optional filtering, sorting, and pagination. Used to find existing admin groups like 'cloud_controller.admin'.", - security = @SecurityRequirement(name = "bearerAuth", scopes = {"scim.read"}) - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Groups retrieved successfully", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "totalResults": 1, - "startIndex": 1, - "itemsPerPage": 1, - "schemas": ["urn:scim:schemas:core:1.0"], - "resources": [{ - "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "displayName": "cloud_controller.admin", - "description": "Cloud Controller Administrators" - }] - } - """))), - @ApiResponse(responseCode = "400", description = "Bad Request - Invalid filter expression"), - @ApiResponse(responseCode = "401", description = "Unauthorized - Invalid or missing authentication token"), - @ApiResponse(responseCode = "403", description = "Forbidden - Insufficient privileges") - }) public SearchResults listGroups( - @Parameter(description = "Comma-separated list of attributes to return", example = "id,displayName,members") @RequestParam(value = "attributes", required = false) String attributesCommaSeparated, - @Parameter(description = "SCIM filter expression for searching groups", example = "displayName eq \"cloud_controller.admin\"") @RequestParam(required = false, defaultValue = "id pr") String filter, - @Parameter(description = "Field to sort by", schema = @Schema(allowableValues = {"created", "displayName", "lastModified"})) @RequestParam(required = false, defaultValue = "created") String sortBy, - @Parameter(description = "Sort order", schema = @Schema(allowableValues = {"ascending", "descending"})) @RequestParam(required = false, defaultValue = "ascending") String sortOrder, - @Parameter(description = "1-based index of first result", schema = @Schema(minimum = "1")) @RequestParam(required = false, defaultValue = "1") int startIndex, - @Parameter(description = "Maximum number of results to return", schema = @Schema(minimum = "1", maximum = "500")) @RequestParam(required = false, defaultValue = "100") int count) { if (count > groupMaxCount) { @@ -410,41 +378,7 @@ public ScimGroup getGroup(@PathVariable String groupId, HttpServletResponse http @PostMapping({"/Groups", "/Groups/"}) @ResponseStatus(HttpStatus.CREATED) @ResponseBody - @Operation( - summary = "Create Group", - description = "Create a new group (admin scope). Used to create admin groups like 'cloud_controller.admin' if they don't exist.", - security = @SecurityRequirement(name = "bearerAuth", scopes = {"scim.write"}) - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "Group created successfully", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "displayName": "cloud_controller.admin", - "description": "Cloud Controller Administrators", - "schemas": ["urn:scim:schemas:core:1.0"], - "meta": { - "version": 0, - "created": "2023-11-17T10:00:00.000Z", - "lastModified": "2023-11-17T10:00:00.000Z" - } - } - """))), - @ApiResponse(responseCode = "400", description = "Bad Request - Invalid request syntax"), - @ApiResponse(responseCode = "401", description = "Unauthorized - Invalid or missing authentication token"), - @ApiResponse(responseCode = "403", description = "Forbidden - Insufficient privileges"), - @ApiResponse(responseCode = "409", description = "Conflict - Group already exists") - }) - public ScimGroup createGroup( - @Parameter(description = "Group to create", required = true, - content = @Content(examples = @ExampleObject(value = """ - { - "displayName": "cloud_controller.admin", - "description": "Cloud Controller Administrators" - } - """))) - @RequestBody ScimGroup group, HttpServletResponse httpServletResponse) { + public ScimGroup createGroup(@RequestBody ScimGroup group, HttpServletResponse httpServletResponse) { group.setZoneId(identityZoneManager.getCurrentIdentityZoneId()); ScimGroup created = dao.create(group, identityZoneManager.getCurrentIdentityZoneId()); if (group.getMembers() != null) { @@ -640,70 +574,14 @@ public ResponseEntity> listGroupMemberships(@PathVariable @PostMapping({"/Groups/{groupId}/members", "/Groups/{groupId}/members/"}) @ResponseStatus(HttpStatus.CREATED) @ResponseBody - @Operation( - summary = "Add Member to Group", - description = "Add a user to a group, effectively assigning an admin role. This is the key operation for granting admin privileges to users.", - security = @SecurityRequirement(name = "bearerAuth", scopes = {"scim.write", "groups.update"}) - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "Member added successfully", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "value": "3ebe4bda-74a2-40c4-8b70-f771d9bc8b9f", - "type": "USER", - "origin": "uaa" - } - """))), - @ApiResponse(responseCode = "400", description = "Bad Request - Invalid request"), - @ApiResponse(responseCode = "401", description = "Unauthorized - Invalid or missing authentication token"), - @ApiResponse(responseCode = "403", description = "Forbidden - Insufficient privileges"), - @ApiResponse(responseCode = "404", description = "Not Found - Group or user not found"), - @ApiResponse(responseCode = "409", description = "Conflict - Member already exists in group") - }) - public ScimGroupMember addMemberToGroup( - @Parameter(description = "UUID of the group", required = true, example = "f47ac10b-58cc-4372-a567-0e02b2c3d479") - @PathVariable String groupId, - @Parameter(description = "Member to add to the group", required = true, - content = @Content(examples = @ExampleObject(value = """ - { - "value": "3ebe4bda-74a2-40c4-8b70-f771d9bc8b9f", - "type": "USER", - "origin": "uaa" - } - """))) - @RequestBody ScimGroupMember member) { - + public ScimGroupMember addMemberToGroup(@PathVariable String groupId,@RequestBody ScimGroupMember member) { return membershipManager.addMember(groupId, member, identityZoneManager.getCurrentIdentityZoneId()); } @DeleteMapping({"/Groups/{groupId}/members/{memberId}", "/Groups/{groupId}/members/{memberId}/"}) @ResponseBody @ResponseStatus(HttpStatus.OK) - @Operation( - summary = "Remove Member from Group", - description = "Remove a user from a group, effectively revoking an admin role. This is used to revoke admin privileges from users.", - security = @SecurityRequirement(name = "bearerAuth", scopes = {"scim.write", "groups.update"}) - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Member removed successfully", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "value": "3ebe4bda-74a2-40c4-8b70-f771d9bc8b9f", - "type": "USER", - "origin": "uaa" - } - """))), - @ApiResponse(responseCode = "401", description = "Unauthorized - Invalid or missing authentication token"), - @ApiResponse(responseCode = "403", description = "Forbidden - Insufficient privileges"), - @ApiResponse(responseCode = "404", description = "Not Found - Group or member not found") - }) - public ScimGroupMember deleteGroupMembership( - @Parameter(description = "UUID of the group", required = true, example = "f47ac10b-58cc-4372-a567-0e02b2c3d479") - @PathVariable String groupId, - @Parameter(description = "UUID of the member to remove", required = true, example = "3ebe4bda-74a2-40c4-8b70-f771d9bc8b9f") - @PathVariable String memberId) { + public ScimGroupMember deleteGroupMembership(@PathVariable String groupId,@PathVariable String memberId) { return membershipManager.removeMemberById(groupId, memberId, identityZoneManager.getCurrentIdentityZoneId()); } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java index a59ad9b8912..591f1855d89 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java @@ -126,7 +126,6 @@ objectName = "cloudfoundry.identity:name=UserEndpoint", description = "UAA User API Metrics" ) -@Tag(name = "Users", description = "SCIM User queries for admin role assignment") public class ScimUserEndpoints implements InitializingBean, ApplicationEventPublisherAware { private static final Logger logger = LoggerFactory.getLogger(ScimUserEndpoints.class); @@ -501,54 +500,12 @@ private int getVersion(String userId, String etag) { @GetMapping({"/Users", "/Users/"}) @ResponseBody - @Operation( - summary = "List/Filter Users", - description = "Query for users with optional filtering, sorting, and pagination. Used to find users to assign admin roles to.", - security = @SecurityRequirement(name = "bearerAuth", scopes = {"scim.read"}) - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Users retrieved successfully", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "totalResults": 1, - "startIndex": 1, - "itemsPerPage": 1, - "schemas": ["urn:scim:schemas:core:1.0"], - "resources": [{ - "id": "3ebe4bda-74a2-40c4-8b70-f771d9bc8b9f", - "userName": "admin", - "emails": [{"value": "admin@example.com", "primary": true}], - "groups": [{ - "value": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "display": "cloud_controller.admin", - "type": "DIRECT" - }] - }] - } - """))), - @ApiResponse(responseCode = "400", description = "Bad Request - Invalid filter expression"), - @ApiResponse(responseCode = "401", description = "Unauthorized - Invalid or missing authentication token"), - @ApiResponse(responseCode = "403", description = "Forbidden - Insufficient privileges") - }) public SearchResults findUsers( - @Parameter(description = "Comma-separated list of attributes to return", example = "id,userName,emails,groups") @RequestParam(value = "attributes", required = false) String attributesCommaSeparated, - @Parameter(description = "SCIM filter expression for searching users", - examples = { - @ExampleObject(name = "By username", value = "userName eq \"admin\""), - @ExampleObject(name = "By email", value = "emails.value eq \"admin@example.com\""), - @ExampleObject(name = "Active users", value = "active eq true"), - @ExampleObject(name = "Users with admin groups", value = "groups.display co \"admin\"") - }) @RequestParam(required = false, defaultValue = "id pr") String filter, - @Parameter(description = "Field to sort by", schema = @Schema(allowableValues = {"created", "userName", "email", "lastModified"})) @RequestParam(required = false, defaultValue = "created") String sortBy, - @Parameter(description = "Sort order", schema = @Schema(allowableValues = {"ascending", "descending"})) @RequestParam(required = false, defaultValue = "ascending") String sortOrder, - @Parameter(description = "1-based index of first result", schema = @Schema(minimum = "1")) @RequestParam(required = false, defaultValue = "1") int startIndex, - @Parameter(description = "Maximum number of results to return", schema = @Schema(minimum = "1", maximum = "500")) @RequestParam(required = false, defaultValue = "100") int count) { if (startIndex < 1) { diff --git a/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java b/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java index 7a949436cb9..8b0d26ad191 100644 --- a/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java +++ b/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java @@ -10,6 +10,7 @@ import io.swagger.v3.oas.models.servers.Server; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springdoc.core.customizers.OpenApiCustomizer; import java.util.List; @@ -49,10 +50,7 @@ public OpenAPI uaaOpenAPI() { .servers(List.of( new Server() .url("http://localhost:8080/uaa") - .description("Local Development"), - new Server() - .url("https://uaa.example.com") - .description("Production UAA Server") + .description("Local Development") )) .addSecurityItem(new SecurityRequirement().addList("bearerAuth")) .components(new Components() @@ -74,4 +72,13 @@ OAuth2 Bearer token (JWT format). """))); } -} + @Bean + public OpenApiCustomizer sortTagsAlphabetically() { + return openApi -> { + if (openApi.getTags() != null) { + // Sort tags Z-A + openApi.getTags().sort((t1, t2) -> t2.getName().compareToIgnoreCase(t1.getName())); + } + }; + } +} \ No newline at end of file diff --git a/uaa/src/main/resources/uaa.yml b/uaa/src/main/resources/uaa.yml index 17719105cec..0e601fe4b20 100755 --- a/uaa/src/main/resources/uaa.yml +++ b/uaa/src/main/resources/uaa.yml @@ -603,6 +603,8 @@ springdoc: url: /v3/api-docs disable-swagger-default-url: true try-it-out-enabled: true + tags-sorter: alpha + operations-sorter: alpha show-actuator: false default-consumes-media-type: application/json default-produces-media-type: application/json From 59bc5ea25c4eb7a33e615d9a797534888cd12907 Mon Sep 17 00:00:00 2001 From: Joe Mahady Date: Fri, 30 Jan 2026 13:04:53 +0000 Subject: [PATCH 4/9] [TNZ-70080]: refactored openapi spec generation --- .../uaa/scim/endpoints/ScimGroupEndpoints.java | 13 ++----------- .../uaa/scim/endpoints/ScimUserEndpoints.java | 9 --------- .../identity/uaa/OpenApiConfiguration.java | 17 +++-------------- 3 files changed, 5 insertions(+), 34 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java index 24f92bee2c8..fae10771aa3 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java @@ -1,15 +1,6 @@ package org.cloudfoundry.identity.uaa.scim.endpoints; import com.jayway.jsonpath.JsonPathException; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; import org.cloudfoundry.identity.uaa.resources.AttributeNameMapper; import org.cloudfoundry.identity.uaa.resources.SearchResults; import org.cloudfoundry.identity.uaa.resources.SearchResultsFactory; @@ -574,14 +565,14 @@ public ResponseEntity> listGroupMemberships(@PathVariable @PostMapping({"/Groups/{groupId}/members", "/Groups/{groupId}/members/"}) @ResponseStatus(HttpStatus.CREATED) @ResponseBody - public ScimGroupMember addMemberToGroup(@PathVariable String groupId,@RequestBody ScimGroupMember member) { + public ScimGroupMember addMemberToGroup(@PathVariable String groupId, @RequestBody ScimGroupMember member) { return membershipManager.addMember(groupId, member, identityZoneManager.getCurrentIdentityZoneId()); } @DeleteMapping({"/Groups/{groupId}/members/{memberId}", "/Groups/{groupId}/members/{memberId}/"}) @ResponseBody @ResponseStatus(HttpStatus.OK) - public ScimGroupMember deleteGroupMembership(@PathVariable String groupId,@PathVariable String memberId) { + public ScimGroupMember deleteGroupMembership(@PathVariable String groupId, @PathVariable String memberId) { return membershipManager.removeMemberById(groupId, memberId, identityZoneManager.getCurrentIdentityZoneId()); } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java index 591f1855d89..7e652b0b452 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java @@ -1,15 +1,6 @@ package org.cloudfoundry.identity.uaa.scim.endpoints; import com.jayway.jsonpath.JsonPathException; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; import lombok.Getter; import org.cloudfoundry.identity.uaa.account.UserAccountStatus; import org.cloudfoundry.identity.uaa.account.event.UserAccountUnlockedEvent; diff --git a/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java b/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java index 8b0d26ad191..c52f325e2c1 100644 --- a/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java +++ b/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java @@ -10,15 +10,14 @@ import io.swagger.v3.oas.models.servers.Server; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springdoc.core.customizers.OpenApiCustomizer; import java.util.List; /** - * OpenAPI 3.0 configuration for UAA SCIM API documentation. + * OpenAPI 3.0 configuration for UAA API documentation. * - * This configuration provides interactive API documentation for UAA SCIM endpoints, - * specifically focused on admin role management capabilities. + * This configuration provides interactive API documentation for all UAA endpoints, + * including users, groups, clients, identity zones, and identity providers. */ @Configuration public class OpenApiConfiguration { @@ -71,14 +70,4 @@ OAuth2 Bearer token (JWT format). Obtain tokens via /oauth/token endpoint. """))); } - - @Bean - public OpenApiCustomizer sortTagsAlphabetically() { - return openApi -> { - if (openApi.getTags() != null) { - // Sort tags Z-A - openApi.getTags().sort((t1, t2) -> t2.getName().compareToIgnoreCase(t1.getName())); - } - }; - } } \ No newline at end of file From 7fbdb1bcdf359885cbe3bdb2c5cf9154c08e568e Mon Sep 17 00:00:00 2001 From: Joe Mahady Date: Fri, 30 Jan 2026 13:09:08 +0000 Subject: [PATCH 5/9] [TNZ-70080]: refactored openapi spec generation --- .../identity/uaa/scim/endpoints/ScimGroupEndpoints.java | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java index fae10771aa3..e22eb12d34e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java @@ -566,6 +566,7 @@ public ResponseEntity> listGroupMemberships(@PathVariable @ResponseStatus(HttpStatus.CREATED) @ResponseBody public ScimGroupMember addMemberToGroup(@PathVariable String groupId, @RequestBody ScimGroupMember member) { + return membershipManager.addMember(groupId, member, identityZoneManager.getCurrentIdentityZoneId()); } From 64f20874ad462fe9c264049206c240e446b59f7c Mon Sep 17 00:00:00 2001 From: Joe Mahady Date: Fri, 30 Jan 2026 13:10:41 +0000 Subject: [PATCH 6/9] [TNZ-70080]: refactored openapi spec generation --- .../identity/uaa/scim/endpoints/ScimGroupEndpoints.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java index e22eb12d34e..b1e61d14bee 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java @@ -566,7 +566,7 @@ public ResponseEntity> listGroupMemberships(@PathVariable @ResponseStatus(HttpStatus.CREATED) @ResponseBody public ScimGroupMember addMemberToGroup(@PathVariable String groupId, @RequestBody ScimGroupMember member) { - + return membershipManager.addMember(groupId, member, identityZoneManager.getCurrentIdentityZoneId()); } From e128cdcc0f75fefcad78c3af5f7cd2c791900e97 Mon Sep 17 00:00:00 2001 From: Joe Mahady Date: Tue, 3 Feb 2026 10:20:53 +0000 Subject: [PATCH 7/9] [TNZ-70080]: utilise buildInfo to populate version and url --- .../identity/uaa/OpenApiConfiguration.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java b/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java index c52f325e2c1..5c517b46dce 100644 --- a/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java +++ b/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; +import org.cloudfoundry.identity.uaa.home.BuildInfo; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -22,6 +23,12 @@ @Configuration public class OpenApiConfiguration { + private final BuildInfo buildInfo; + + public OpenApiConfiguration(BuildInfo buildInfo) { + this.buildInfo = buildInfo; + } + @Bean public OpenAPI uaaOpenAPI() { return new OpenAPI() @@ -39,17 +46,17 @@ public OpenAPI uaaOpenAPI() { - Multi-tenancy via identity zones - Client application management """) - .version("1.0.0") + .version(buildInfo.getVersion()) .contact(new Contact() - .name("UAA Team") + .name("Cloudfoundry Foundation") .url("https://github.com/cloudfoundry/uaa")) .license(new License() .name("Apache 2.0") .url("https://www.apache.org/licenses/LICENSE-2.0"))) .servers(List.of( new Server() - .url("http://localhost:8080/uaa") - .description("Local Development") + .url(buildInfo.getUaaUrl()) + .description("UAA Server") )) .addSecurityItem(new SecurityRequirement().addList("bearerAuth")) .components(new Components() From 82511631d3d62929cdaa03797bb1e74ae651b93d Mon Sep 17 00:00:00 2001 From: Joe Mahady Date: Wed, 4 Feb 2026 09:20:22 +0000 Subject: [PATCH 8/9] OpenAPI document endpoints, update yaml files --- scripts/boot/uaa.yml | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/scripts/boot/uaa.yml b/scripts/boot/uaa.yml index 32c2e9a2c31..bbbcd7c08b4 100644 --- a/scripts/boot/uaa.yml +++ b/scripts/boot/uaa.yml @@ -365,25 +365,3 @@ oauth: - roles - user_attributes - uaa.offline_token - -springdoc: - api-docs: - enabled: true - path: /v3/api-docs - resolve-schema-properties: true - swagger-ui: - enabled: true - path: /swagger-ui.html - url: /v3/api-docs - disable-swagger-default-url: true - try-it-out-enabled: true - tags-sorter: alpha - operations-sorter: alpha - show-actuator: false - default-consumes-media-type: application/json - default-produces-media-type: application/json - disable-swagger-default-url: true - use-management-port: false - writer-with-default-pretty-printer: false - model-and-view-allowed: false - override-with-generic-response: false \ No newline at end of file From 4c4f79c94f01106f40bccb7849a86065e299ce4b Mon Sep 17 00:00:00 2001 From: Joe Mahady Date: Thu, 12 Feb 2026 11:25:13 +0000 Subject: [PATCH 9/9] OpenAPI document endpoints, added tests --- .../uaa/impl/config/UaaConfiguration.java | 1 + .../identity/uaa/OpenApiConfiguration.java | 2 +- uaa/src/main/resources/uaa.yml | 1 - .../uaa/OpenApiConfigurationTest.java | 119 ++++++++++++++++++ 4 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 uaa/src/test/java/org/cloudfoundry/identity/uaa/OpenApiConfigurationTest.java diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/impl/config/UaaConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/impl/config/UaaConfiguration.java index 7402730adc3..a77248a1ba9 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/impl/config/UaaConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/impl/config/UaaConfiguration.java @@ -117,6 +117,7 @@ public class UaaConfiguration { public Integer groupMaxCount; public Integer clientMaxCount; public RateLimit ratelimit; + public Map springdoc; public static class Zones { @Valid diff --git a/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java b/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java index 5c517b46dce..2202eb6f818 100644 --- a/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java +++ b/uaa/src/main/java/org/cloudfoundry/identity/uaa/OpenApiConfiguration.java @@ -48,7 +48,7 @@ public OpenAPI uaaOpenAPI() { """) .version(buildInfo.getVersion()) .contact(new Contact() - .name("Cloudfoundry Foundation") + .name("Cloud Foundry Foundation") .url("https://github.com/cloudfoundry/uaa")) .license(new License() .name("Apache 2.0") diff --git a/uaa/src/main/resources/uaa.yml b/uaa/src/main/resources/uaa.yml index 0e601fe4b20..166324fa048 100755 --- a/uaa/src/main/resources/uaa.yml +++ b/uaa/src/main/resources/uaa.yml @@ -608,7 +608,6 @@ springdoc: show-actuator: false default-consumes-media-type: application/json default-produces-media-type: application/json - disable-swagger-default-url: true use-management-port: false writer-with-default-pretty-printer: false model-and-view-allowed: false diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/OpenApiConfigurationTest.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/OpenApiConfigurationTest.java new file mode 100644 index 00000000000..beb6f2bc32a --- /dev/null +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/OpenApiConfigurationTest.java @@ -0,0 +1,119 @@ +package org.cloudfoundry.identity.uaa; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.cloudfoundry.identity.uaa.home.BuildInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class OpenApiConfigurationTest { + + private BuildInfo buildInfo; + private OpenApiConfiguration openApiConfiguration; + + @BeforeEach + void setUp() { + buildInfo = mock(BuildInfo.class); + when(buildInfo.getVersion()).thenReturn("1.0.0-test"); + when(buildInfo.getUaaUrl()).thenReturn("https://uaa.example.com"); + openApiConfiguration = new OpenApiConfiguration(buildInfo); + } + + @Test + void uaaOpenAPIBeanIsCreated() { + OpenAPI openAPI = openApiConfiguration.uaaOpenAPI(); + + assertThat(openAPI).isNotNull(); + } + + @Test + void openAPIHasCorrectTitle() { + OpenAPI openAPI = openApiConfiguration.uaaOpenAPI(); + + assertThat(openAPI.getInfo().getTitle()).isEqualTo("UAA API Reference"); + } + + @Test + void openAPIHasDescriptionWithKeyFeatures() { + OpenAPI openAPI = openApiConfiguration.uaaOpenAPI(); + + String description = openAPI.getInfo().getDescription(); + assertThat(description) + .contains("OAuth2/OpenID Connect") + .contains("SCIM 2.0") + .contains("Identity provider integration") + .contains("Multi-tenancy"); + } + + @Test + void openAPIVersionComesFromBuildInfo() { + OpenAPI openAPI = openApiConfiguration.uaaOpenAPI(); + + assertThat(openAPI.getInfo().getVersion()).isEqualTo("1.0.0-test"); + } + + @Test + void openAPIHasCorrectContactInfo() { + OpenAPI openAPI = openApiConfiguration.uaaOpenAPI(); + + assertThat(openAPI.getInfo().getContact().getName()).isEqualTo("Cloud Foundry Foundation"); + assertThat(openAPI.getInfo().getContact().getUrl()).isEqualTo("https://github.com/cloudfoundry/uaa"); + } + + @Test + void openAPIHasApache2License() { + OpenAPI openAPI = openApiConfiguration.uaaOpenAPI(); + + assertThat(openAPI.getInfo().getLicense().getName()).isEqualTo("Apache 2.0"); + assertThat(openAPI.getInfo().getLicense().getUrl()).isEqualTo("https://www.apache.org/licenses/LICENSE-2.0"); + } + + @Test + void openAPIServerUrlComesFromBuildInfo() { + OpenAPI openAPI = openApiConfiguration.uaaOpenAPI(); + + assertThat(openAPI.getServers()).hasSize(1); + assertThat(openAPI.getServers().get(0).getUrl()).isEqualTo("https://uaa.example.com"); + assertThat(openAPI.getServers().get(0).getDescription()).isEqualTo("UAA Server"); + } + + @Test + void openAPIHasBearerAuthSecurityRequirement() { + OpenAPI openAPI = openApiConfiguration.uaaOpenAPI(); + + assertThat(openAPI.getSecurity()).hasSize(1); + assertThat(openAPI.getSecurity().get(0).get("bearerAuth")).isNotNull(); + } + + @Test + void openAPIHasBearerAuthSecurityScheme() { + OpenAPI openAPI = openApiConfiguration.uaaOpenAPI(); + + SecurityScheme securityScheme = openAPI.getComponents().getSecuritySchemes().get("bearerAuth"); + assertThat(securityScheme).isNotNull(); + assertThat(securityScheme.getType()).isEqualTo(SecurityScheme.Type.HTTP); + assertThat(securityScheme.getScheme()).isEqualTo("bearer"); + assertThat(securityScheme.getBearerFormat()).isEqualTo("JWT"); + } + + @Test + void securitySchemeDescriptionDocumentsRequiredScopes() { + OpenAPI openAPI = openApiConfiguration.uaaOpenAPI(); + + String description = openAPI.getComponents().getSecuritySchemes().get("bearerAuth").getDescription(); + assertThat(description) + .contains("uaa.admin") + .contains("scim.read") + .contains("scim.write") + .contains("clients.read") + .contains("clients.write") + .contains("zones.read") + .contains("zones.write") + .contains("idps.read") + .contains("idps.write"); + } +}