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