diff --git a/jans-config-api/plugins/admin-ui-plugin/pom.xml b/jans-config-api/plugins/admin-ui-plugin/pom.xml index 00c74f797ac..7ddfe595d37 100644 --- a/jans-config-api/plugins/admin-ui-plugin/pom.xml +++ b/jans-config-api/plugins/admin-ui-plugin/pom.xml @@ -31,6 +31,11 @@ commons-collections 3.2.2 + + org.apache.tika + tika-core + 2.9.2 + @@ -83,6 +88,17 @@ src/test/resources true + + + **/*.cjar + + + + src/test/resources + false + + **/*.cjar + diff --git a/jans-config-api/plugins/admin-ui-plugin/src/main/assembly/assembly.xml b/jans-config-api/plugins/admin-ui-plugin/src/main/assembly/assembly.xml index 2d4d732de45..16bd260ccec 100644 --- a/jans-config-api/plugins/admin-ui-plugin/src/main/assembly/assembly.xml +++ b/jans-config-api/plugins/admin-ui-plugin/src/main/assembly/assembly.xml @@ -1,11 +1,22 @@ - + xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.1.0 http://maven.apache.org/xsd/assembly-2.1.0.xsd"> distribution jar false + + + true + / + false + + org.apache.tika:tika-core + + runtime + + ${project.build.directory}/classes @@ -15,4 +26,4 @@ - \ No newline at end of file + diff --git a/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/model/adminui/AdminUIPolicyStore.java b/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/model/adminui/AdminUIPolicyStore.java index 22b8acaf962..acd2b038708 100644 --- a/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/model/adminui/AdminUIPolicyStore.java +++ b/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/model/adminui/AdminUIPolicyStore.java @@ -1,74 +1,147 @@ package io.jans.ca.plugin.adminui.model.adminui; -import io.jans.service.document.store.model.Document; +import com.fasterxml.jackson.annotation.JsonView; +import io.jans.ca.plugin.adminui.model.adminui.PolicyStoreViews.Create; +import io.jans.ca.plugin.adminui.model.adminui.PolicyStoreViews.Edit; +import io.jans.orm.annotation.AttributeName; +import io.jans.orm.annotation.DN; +import io.jans.orm.annotation.DataEntry; +import io.jans.orm.annotation.ObjectClass; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; -import jakarta.ws.rs.FormParam; -import jakarta.ws.rs.core.MediaType; -import org.jboss.resteasy.annotations.providers.multipart.PartType; -import java.io.InputStream; - -public class AdminUIPolicyStore { - - @NotNull - @Valid - @FormParam("document") - @PartType(MediaType.APPLICATION_JSON) - private Document document; - - @NotNull - @FormParam("policyStore") - @PartType(MediaType.APPLICATION_OCTET_STREAM) - @Schema(implementation = String.class, format="binary") - private InputStream policyStore; - - /** - * Retrieve the uploaded policy store binary stream. - * - * @return the `policyStore` InputStream containing the binary content submitted via the multipart form field "policyStore" - */ - public InputStream getPolicyStore() { - return policyStore; - } +import java.util.Date; - /** - * Assigns the input stream that contains the binary policy store. - * - * @param policyStore the input stream for the policy store data - */ - public void setPolicyStore(InputStream policyStore) { - this.policyStore = policyStore; - } + @DataEntry(sortBy = {"jansStatus"}) + @ObjectClass(value = "adminUIPolicyStore") + public class AdminUIPolicyStore { + // Read-only on edit: settable only when creating the policy store. + @DN + @JsonView(Create.class) + @Schema(description = "Distinguished Name (DN) of the policy store entry. Server-generated on create; read-only and ignored on edit.", accessMode = Schema.AccessMode.READ_ONLY, example = "inum=e1a2b3c4-1234-5678-9abc-def012345678,ou=policy-stores,ou=admin-ui,o=jans") + private String dn; + @AttributeName( + ignoreDuringUpdate = true + ) + @JsonView(Create.class) + @Schema(description = "Unique identifier (inum) of the policy store. Server-generated on create; read-only and ignored on edit.", accessMode = Schema.AccessMode.READ_ONLY, example = "e1a2b3c4-1234-5678-9abc-def012345678") + private String inum; + // Editable metadata: writable on both create and edit. + @AttributeName(name = "displayname") + @JsonView({Create.class, Edit.class}) + @Schema(description = "Human-readable display name of the policy store.", example = "Admin UI Cedarling Policy Store") + private String displayname; + @AttributeName(name = "description") + @JsonView({Create.class, Edit.class}) + @Schema(description = "Free-text description of the policy store and its purpose.", example = "Cedarling policy store used to derive Admin UI role-to-scope mappings") + private String description; + // Read-only on edit: the policy store document itself is immutable once uploaded. + @AttributeName(name = "document") + @JsonView(Create.class) + @Schema(description = "Base64-encoded Cedar policy store archive (.cjar zip). Required on create; immutable and ignored on edit.", example = "UEsDBBQACAgIAABb...==") + private String policyStore; + // Read-only on edit: owner cannot be reassigned via edit. + @AttributeName(name = "jansUsrDN") + @JsonView(Create.class) + @Schema(description = "DN of the user who owns the policy store. Set at create time; cannot be reassigned via edit.", example = "inum=abc123,ou=people,o=jans") + private String jansUsrDN; + // Editable: status may be changed on both create and edit. + @AttributeName(name = "jansStatus") + @JsonView({Create.class, Edit.class}) + @Schema(description = "Status of the policy store. Only one store should be 'active' at a time; newly created stores default to 'inactive'.", allowableValues = {"active", "inactive"}, example = "active") + private String jansStatus; + // Read-only on edit: creation timestamp is fixed at create time. + @AttributeName(name = "creationDate") + @JsonView(Create.class) + @Schema(description = "Timestamp when the policy store was created. Server-managed and fixed at create time.", accessMode = Schema.AccessMode.READ_ONLY, example = "2026-07-14T10:15:30.000Z") + private Date creationDate = new Date(); + // Server-managed: overwritten on every create/edit, so any client-supplied value is ignored. + @AttributeName(name = "jansLastUpd") + @Schema(description = "Timestamp of the last update. Server-managed and overwritten on every create/edit; any client-supplied value is ignored.", accessMode = Schema.AccessMode.READ_ONLY, example = "2026-07-14T11:20:45.000Z") + private Date jansLastUpd; - /** - * Retrieves the JSON `Document` supplied in the multipart form field named "document". - * - * @return the `Document` parsed from the multipart "document" field - */ - public Document getDocument() { - return document; - } + public String getDn() { + return dn; + } - /** - * Assigns the JSON document corresponding to the multipart form field named "document". - * - * @param document the JSON `Document` bound from the multipart form part named "document" - */ - public void setDocument(Document document) { - this.document = document; - } + public void setDn(String dn) { + this.dn = dn; + } + + public String getInum() { + return inum; + } + + public void setInum(String inum) { + this.inum = inum; + } + + public String getDisplayname() { + return displayname; + } + + public void setDisplayname(String displayname) { + this.displayname = displayname; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getPolicyStore() { + return policyStore; + } + + public void setPolicyStore(String policyStore) { + this.policyStore = policyStore; + } + + public String getJansUsrDN() { + return jansUsrDN; + } + + public void setJansUsrDN(String jansUsrDN) { + this.jansUsrDN = jansUsrDN; + } + + public String getJansStatus() { + return jansStatus; + } + + public void setJansStatus(String jansStatus) { + this.jansStatus = jansStatus; + } + + public Date getCreationDate() { + return creationDate; + } + + public void setCreationDate(Date creationDate) { + this.creationDate = creationDate; + } + + public Date getJansLastUpd() { + return jansLastUpd; + } + + public void setJansLastUpd(Date jansLastUpd) { + this.jansLastUpd = jansLastUpd; + } - /** - * Produce a string representation of this AdminUIPolicyStore that includes the `policyStore` field. - * - * @return a string containing the class name and the `policyStore` value - */ - @Override - public String toString() { - return "AdminUIPolicyStore{" + - "document=" + document + - ", policyStore=" + policyStore + - '}'; + @Override + public String toString() { + return "AdminUIPolicyStore{" + + "dn='" + dn + '\'' + + ", inum='" + inum + '\'' + + ", displayname='" + displayname + '\'' + + ", description='" + description + '\'' + + ", policyStore='" + policyStore + '\'' + + ", jansUsrDN='" + jansUsrDN + '\'' + + ", jansStatus='" + jansStatus + '\'' + + ", creationDate=" + creationDate + + ", jansLastUpd=" + jansLastUpd + + '}'; + } } -} diff --git a/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/model/adminui/PolicyStoreViews.java b/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/model/adminui/PolicyStoreViews.java new file mode 100644 index 00000000000..ec386c153a1 --- /dev/null +++ b/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/model/adminui/PolicyStoreViews.java @@ -0,0 +1,28 @@ +package io.jans.ca.plugin.adminui.model.adminui; + +/** + * Jackson {@code @JsonView} marker interfaces used to control which + * {@link AdminUIPolicyStore} fields may be supplied by clients on different + * REST operations. + * + * + */ +public final class PolicyStoreViews { + + private PolicyStoreViews() { + } + + /** Fields accepted when creating a policy store (POST). */ + public interface Create { + } + + /** Fields accepted when editing an existing policy store (PUT). */ + public interface Edit { + } +} diff --git a/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/rest/adminui/AdminUISecurityResource.java b/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/rest/adminui/AdminUISecurityResource.java index cd566839889..89a2154a9b4 100644 --- a/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/rest/adminui/AdminUISecurityResource.java +++ b/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/rest/adminui/AdminUISecurityResource.java @@ -1,31 +1,43 @@ package io.jans.ca.plugin.adminui.rest.adminui; +import com.fasterxml.jackson.annotation.JsonView; import io.jans.ca.plugin.adminui.model.adminui.AdminUIPolicyStore; +import io.jans.ca.plugin.adminui.model.adminui.PolicyStoreViews; import io.jans.ca.plugin.adminui.model.auth.GenericResponse; import io.jans.ca.plugin.adminui.model.exception.ApplicationException; import io.jans.ca.plugin.adminui.service.adminui.AdminUISecurityService; import io.jans.ca.plugin.adminui.utils.AppConstants; import io.jans.ca.plugin.adminui.utils.CommonUtils; import io.jans.ca.plugin.adminui.utils.ErrorResponse; +import io.jans.configapi.core.rest.BaseResource; import io.jans.configapi.core.rest.ProtectedApi; +import io.jans.configapi.util.ApiConstants; +import io.jans.model.SearchRequest; +import io.jans.orm.model.PagedResult; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; 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.parameters.RequestBody; 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 jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import org.jboss.resteasy.annotations.providers.multipart.MultipartForm; import org.slf4j.Logger; +import static io.jans.as.model.util.Util.escapeLog; + @Path("/admin-ui/security") -public class AdminUISecurityResource { +public class AdminUISecurityResource extends BaseResource { static final String POLICY_STORE = "policyStore"; + public static final String POLICY_STORE_INUM = "/{INUM}"; static final String SYNC_ROLE_SCOPES_MAPPING = "/syncRoleScopesMapping"; static final String SECURITY_READ = "https://jans.io/oauth/jans-auth-server/config/adminui/security.readonly"; static final String SECURITY_WRITE = "https://jans.io/oauth/jans-auth-server/config/adminui/security.write"; @@ -37,19 +49,26 @@ public class AdminUISecurityResource { AdminUISecurityService adminUISecurityService; /** - * Retrieve the Admin UI policy store. + * Searches Admin UI Cedarling policy stores. * - *

On success returns a response whose entity is a GenericResponse containing the policy store - * payload. On error returns a response whose entity is a GenericResponse with error details and - * an appropriate HTTP status code. + *

Builds a paginated {@link SearchRequest} from the supplied query parameters and + * delegates the lookup to {@link AdminUISecurityService}. Results are ordered and + * paged according to the request parameters.

* - * @return a Response whose entity is a GenericResponse with the policy store on success or error details on failure + * @param limit maximum number of results to return + * @param pattern substring pattern used to filter results (empty matches all) + * @param startIndex 1-based index of the first result to return + * @param sortBy attribute used to order the results (defaults to inum) + * @param sortOrder sort direction ("ascending" or "descending") + * @param fieldValuePair comma-separated field/value pairs used for exact-match filtering + * @return HTTP 200 with the matching page of policy stores, or an error response + * (400/500) wrapped in a {@link GenericResponse} on failure */ @Operation(summary = "Get Admin UI policy store", description = "Get Admin UI policy store", operationId = "get-adminui-policy-store", tags = { "Admin UI - Cedarling"}, security = @SecurityRequirement(name = "oauth2", scopes = { SECURITY_READ})) @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Ok", content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = GenericResponse.class, description = "Get Admin UI policy store")))), + @ApiResponse(responseCode = "200", description = "Ok", content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = AdminUIPolicyStore.class, description = "Get Admin UI policy store")), examples = @ExampleObject(name = "Response json example", value = "example/adminui/security/get-all-policy-store-response.json"))), @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = GenericResponse.class, description = "Bad Request"))), @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "500", description = "InternalServerError", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = GenericResponse.class, description = "InternalServerError")))}) @@ -57,20 +76,33 @@ public class AdminUISecurityResource { @Path(POLICY_STORE) @Produces(MediaType.APPLICATION_JSON) @ProtectedApi(scopes = {SECURITY_READ}, groupScopes = {SECURITY_WRITE}, superScopes = {AppConstants.SCOPE_ADMINUI_WRITE}) - public Response getPolicyStore() { + public Response getPolicyStore( + @Parameter(description = "Search size - max size of the results to return") @DefaultValue(ApiConstants.DEFAULT_LIST_SIZE) @QueryParam(value = ApiConstants.LIMIT) int limit, + @Parameter(description = "Search pattern") @DefaultValue("") @QueryParam(value = ApiConstants.PATTERN) String pattern, + @Parameter(description = "The 1-based index of the first query result") @DefaultValue(ApiConstants.DEFAULT_LIST_START_INDEX) @QueryParam(value = ApiConstants.START_INDEX) int startIndex, + @Parameter(description = "Attribute whose value will be used to order the returned response") @DefaultValue(AppConstants.INUM) @QueryParam(value = ApiConstants.SORT_BY) String sortBy, + @Parameter(description = "Order in which the sortBy param is applied. Allowed values are \"ascending\" and \"descending\"") @DefaultValue(ApiConstants.ASCENDING) @QueryParam(value = ApiConstants.SORT_ORDER) String sortOrder, + @Parameter(description = "Field and value pair for seraching", examples = @ExampleObject(name = "Field value example", value = "scopeType=spontaneous,defaultScope=true")) @DefaultValue("") @QueryParam(value = ApiConstants.FIELD_VALUE_PAIR) String fieldValuePair + ) { try { - log.info("Get Admin UI policy store."); - GenericResponse response = adminUISecurityService.getPolicyStore(); + if (log.isInfoEnabled()) { + log.info("Policy Store search param - limit:{}, pattern:{}, startIndex:{}, sortBy:{}, sortOrder:{}, fieldValuePair:{}", + escapeLog(limit), escapeLog(pattern), escapeLog(startIndex), escapeLog(sortBy), + escapeLog(sortOrder), escapeLog(fieldValuePair)); + } + SearchRequest searchReq = createSearchRequest(AppConstants.POLICY_STORE_DN, pattern, sortBy, sortOrder, + startIndex, limit, null, null, adminUISecurityService.getRecordMaxCount(), fieldValuePair, AdminUIPolicyStore.class); log.info("Policy Store received."); - return Response.ok(response).build(); + return Response.ok(this.doSearch(searchReq)).build(); + } catch (ApplicationException e) { - log.error(ErrorResponse.GET_ADMIUI_CONFIG_ERROR.getDescription(), e); + log.error(ErrorResponse.POLICY_STORE_GET_ERROR.getDescription(), e); return Response .status(e.getErrorCode()) .entity(CommonUtils.createGenericResponse(false, e.getErrorCode(), e.getMessage())) .build(); } catch (Exception e) { - log.error(ErrorResponse.GET_ADMIUI_CONFIG_ERROR.getDescription(), e); + log.error(ErrorResponse.POLICY_STORE_GET_ERROR.getDescription(), e); return Response .serverError() .entity(CommonUtils.createGenericResponse(false, 500, e.getMessage())) @@ -79,32 +111,34 @@ public Response getPolicyStore() { } /** - * Upload an Admin UI policy store provided as a multipart form. + * Creates a new Admin UI Cedarling policy store. * - * @param adminUIPolicyStore the multipart form representing the policy store to upload - * @return a JAX-RS Response whose entity is a GenericResponse describing the operation result; - * on success the GenericResponse indicates the upload completed, on error it contains - * an error code and message + *

The request body is deserialized using the {@link PolicyStoreViews.Create} view, so + * only creation-time fields are accepted; the store is validated and persisted with a + * server-generated inum and an {@code inactive} status.

+ * + * @param adminUIPolicyStore the policy store to create (must include the base64 policy-store document) + * @return HTTP 200 with a {@link GenericResponse} describing the outcome, or an error + * response (400/500) on validation or persistence failure */ - @Operation(summary = "Upload Admin UI Policy Store", description = "Upload Admin UI Policy Store", operationId = "upload-adminui-policy-store", tags = { + @Operation(summary = "Create Admin UI Policy Store", description = "Create Admin UI Policy Store", operationId = "create-adminui-policy-store", tags = { "Admin UI - Cedarling"}, security = @SecurityRequirement(name = "oauth2", scopes = { SECURITY_WRITE})) - @RequestBody(description = "String multipart form.", content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA, schema = @Schema(implementation = AdminUIPolicyStore.class))) + @RequestBody(description = "Policy Store Object", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = AdminUIPolicyStore.class), examples = @ExampleObject(name = "Request json example", value = "example/adminui/security/create-policy-store-request.json"))) @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Ok", content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = GenericResponse.class, description = "Upload Admin UI policy store")))), + @ApiResponse(responseCode = "200", description = "Ok", content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = GenericResponse.class, description = "Create Admin UI policy store")), examples = @ExampleObject(name = "Response json example", value = "example/adminui/security/create-policy-store-response.json"))), @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = GenericResponse.class, description = "Bad Request"))), @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "500", description = "InternalServerError", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = GenericResponse.class, description = "InternalServerError")))}) - @Consumes(MediaType.MULTIPART_FORM_DATA) - @PUT + @POST @Path(POLICY_STORE) @Produces(MediaType.APPLICATION_JSON) @ProtectedApi(scopes = {SECURITY_WRITE}, superScopes = {AppConstants.SCOPE_ADMINUI_WRITE}) - public Response uploadPolicyStore(@MultipartForm AdminUIPolicyStore adminUIPolicyStore) { + public Response createPolicyStore(@JsonView(PolicyStoreViews.Create.class) @Valid @NotNull AdminUIPolicyStore adminUIPolicyStore) { try { - log.info("Uploading Admin UI Policy Store."); + log.info("Create Admin UI Policy Store."); GenericResponse response = adminUISecurityService.uploadPolicyStore(adminUIPolicyStore); - log.info("Successfully uploaded Admin UI Policy Store"); + log.info("Successfully created Admin UI Policy Store"); return Response.ok(response).build(); } catch (ApplicationException e) { log.error(ErrorResponse.POLICY_STORE_UPLOAD_ERROR.getDescription(), e); @@ -121,11 +155,113 @@ public Response uploadPolicyStore(@MultipartForm AdminUIPolicyStore adminUIPolic } } + /** + * Edits an existing Admin UI Cedarling policy store. + * + *

The request body is deserialized using the {@link PolicyStoreViews.Edit} view, so only + * editable fields (display name, description, status) are applied; read-only fields are + * preserved from persistence. Activating a store demotes any other active store to keep a + * single active policy store.

+ * + * @param inum the inum of the policy store to edit + * @param adminUIPolicyStore the editable fields to apply + * @return HTTP 200 with a {@link GenericResponse} describing the outcome, or an error + * response (400/404/500) if the request is invalid, the store is missing, or update fails + */ + @Operation(summary = "Edit Admin UI Policy Store", description = "Edit Admin UI Policy Store", operationId = "edit-adminui-policy-store", tags = { + "Admin UI - Cedarling"}, security = @SecurityRequirement(name = "oauth2", scopes = { + SECURITY_WRITE})) + @RequestBody(description = "Policy Store Object", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = AdminUIPolicyStore.class), examples = @ExampleObject(name = "Request json example", value = "example/adminui/security/edit-policy-store-request.json"))) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Ok", content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = GenericResponse.class, description = "Edit Admin UI policy store")), examples = @ExampleObject(name = "Response json example", value = "example/adminui/security/edit-policy-store-response.json"))), + @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = GenericResponse.class, description = "Bad Request"))), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "404", description = "Not Found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = GenericResponse.class, description = "Not Found"))), + @ApiResponse(responseCode = "500", description = "InternalServerError", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = GenericResponse.class, description = "InternalServerError")))}) + @PUT + @Path(POLICY_STORE + POLICY_STORE_INUM) + @Produces(MediaType.APPLICATION_JSON) + @ProtectedApi(scopes = {SECURITY_WRITE}, superScopes = {AppConstants.SCOPE_ADMINUI_WRITE}) + public Response editPolicyStore( + @Parameter(description = "Policy store inum") @PathParam("INUM") @NotNull String inum, + @JsonView(PolicyStoreViews.Edit.class) @Valid @NotNull AdminUIPolicyStore adminUIPolicyStore) { + try { + log.info("Edit Admin UI Policy Store: {}", escapeLog(inum)); + GenericResponse response = adminUISecurityService.editPolicyStore(inum, adminUIPolicyStore); + log.info("Successfully edited Admin UI Policy Store"); + return Response.ok(response).build(); + } catch (ApplicationException e) { + log.error(ErrorResponse.POLICY_STORE_UPDATE_ERROR.getDescription(), e); + return Response + .status(e.getErrorCode()) + .entity(CommonUtils.createGenericResponse(false, e.getErrorCode(), e.getMessage())) + .build(); + } catch (Exception e) { + log.error(ErrorResponse.POLICY_STORE_UPDATE_ERROR.getDescription(), e); + return Response + .serverError() + .entity(CommonUtils.createGenericResponse(false, 500, e.getMessage())) + .build(); + } + } + + /** + * Deletes an Admin UI Cedarling policy store by its inum. + * + * @param inum the inum of the policy store to delete + * @return HTTP 200 with a {@link GenericResponse} on success, or an error response + * (400/404/500) if the inum is blank, the store is missing, or deletion fails + */ + @Operation(summary = "Delete Admin UI Policy Store", description = "Delete Admin UI Policy Store", operationId = "delete-adminui-policy-store", tags = { + "Admin UI - Cedarling"}, security = @SecurityRequirement(name = "oauth2", scopes = { + SECURITY_WRITE})) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Ok", content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = GenericResponse.class, description = "Delete Admin UI policy store")), examples = @ExampleObject(name = "Response json example", value = "example/adminui/security/delete-policy-store-response.json"))), + @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = GenericResponse.class, description = "Bad Request"))), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "404", description = "Not Found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = GenericResponse.class, description = "Not Found"))), + @ApiResponse(responseCode = "500", description = "InternalServerError", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = GenericResponse.class, description = "InternalServerError")))}) + @DELETE + @Path(POLICY_STORE + POLICY_STORE_INUM) + @Produces(MediaType.APPLICATION_JSON) + @ProtectedApi(scopes = {SECURITY_WRITE}, superScopes = {AppConstants.SCOPE_ADMINUI_WRITE}) + public Response deletePolicyStore( + @Parameter(description = "Policy store inum") @PathParam("INUM") @NotNull String inum) { + try { + log.info("Delete Admin UI Policy Store: {}", escapeLog(inum)); + GenericResponse response = adminUISecurityService.deletePolicyStore(inum); + log.info("Successfully deleted Admin UI Policy Store"); + return Response.ok(response).build(); + } catch (ApplicationException e) { + log.error(ErrorResponse.POLICY_STORE_DELETE_ERROR.getDescription(), e); + return Response + .status(e.getErrorCode()) + .entity(CommonUtils.createGenericResponse(false, e.getErrorCode(), e.getMessage())) + .build(); + } catch (Exception e) { + log.error(ErrorResponse.POLICY_STORE_DELETE_ERROR.getDescription(), e); + return Response + .serverError() + .entity(CommonUtils.createGenericResponse(false, 500, e.getMessage())) + .build(); + } + } + + /** + * Synchronizes Admin UI role-to-scope mappings from the active policy store. + * + *

Delegates to {@link AdminUISecurityService#syncRoleScopeMapping()}, which parses the + * active Cedar policy store and refreshes Admin UI roles and role-permission mappings so + * that Admin UI access control stays consistent with the Cedar authorization policies.

+ * + * @return HTTP 200 with a {@link GenericResponse} on success, or an error response + * (400/500) if synchronization fails + */ @Operation(summary = "Sync role-to-scope mappings from the policy store", description = "Sync the role-to-scope mappings from the policy store. If a remote policy store URL is configured and enabled, the mappings will be generated from the remote policy store; otherwise, they will be generated from the default policy store.", operationId = "sync-role-to-scopes-mappings", tags = { "Admin UI - Cedarling"}, security = @SecurityRequirement(name = "oauth2", scopes = { SECURITY_WRITE})) @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Ok", content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = GenericResponse.class, description = "Sync Role-to-Scopes mapping from policy-store")))), + @ApiResponse(responseCode = "200", description = "Ok", content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = GenericResponse.class, description = "Sync Role-to-Scopes mapping from policy-store")), examples = @ExampleObject(name = "Response json example", value = "example/adminui/security/sync-role-scopes-mapping-response.json"))), @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = GenericResponse.class, description = "Bad Request"))), @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "500", description = "InternalServerError", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = GenericResponse.class, description = "InternalServerError")))}) @@ -153,4 +289,30 @@ public Response syncRoleScopeMapping() { .build(); } } + + /** + * Executes a policy-store search against the service and logs the paged result. + * + * @param searchReq the fully built search request + * @return the {@link PagedResult} of matching policy stores (may be {@code null} if none) + * @throws ApplicationException if the underlying search fails + */ + private PagedResult doSearch(SearchRequest searchReq) throws ApplicationException { + if (log.isDebugEnabled()) { + log.debug("Policy Store search params - searchReq:{} ", searchReq); + } + + PagedResult pagedResult = adminUISecurityService.searchPolicyStores(searchReq); + if (log.isTraceEnabled()) { + log.trace("PagedResult - pagedResult:{}", pagedResult); + } + + if (pagedResult != null) { + log.debug( + "Policy Store fetched - pagedResult.getTotalEntriesCount():{}, pagedResult.getEntriesCount():{}, pagedResult.getEntries():{}", + pagedResult.getTotalEntriesCount(), pagedResult.getEntriesCount(), pagedResult.getEntries()); + } + log.debug("Policy Store - pagedResult:{}", pagedResult); + return pagedResult; + } } diff --git a/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/service/adminui/AdminUISecurityService.java b/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/service/adminui/AdminUISecurityService.java index 0f7c534372f..6c52b2fc735 100644 --- a/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/service/adminui/AdminUISecurityService.java +++ b/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/service/adminui/AdminUISecurityService.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.google.common.base.Strings; +import io.jans.as.common.util.AttributeConstants; import io.jans.as.model.config.adminui.AdminRole; import io.jans.as.model.config.adminui.RolePermissionMapping; import io.jans.ca.plugin.adminui.model.adminui.AdminUIPolicyStore; @@ -13,13 +14,19 @@ import io.jans.ca.plugin.adminui.utils.CommonUtils; import io.jans.ca.plugin.adminui.utils.ErrorResponse; import io.jans.ca.plugin.adminui.utils.security.PolicyToScopeMapper; +import io.jans.configapi.configuration.ConfigurationFactory; import io.jans.configapi.core.model.adminui.AUIConfiguration; +import io.jans.configapi.util.ApiConstants; +import io.jans.model.SearchRequest; import io.jans.orm.PersistenceEntryManager; +import io.jans.orm.model.PagedResult; +import io.jans.orm.model.SortOrder; import io.jans.orm.search.filter.Filter; -import io.jans.service.document.store.model.Document; import jakarta.inject.Inject; import jakarta.inject.Singleton; import jakarta.ws.rs.core.Response; +import org.apache.commons.collections.CollectionUtils; +import org.apache.tika.Tika; import org.json.JSONObject; import org.slf4j.Logger; @@ -32,28 +39,28 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; import java.util.*; import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipInputStream; +import static io.jans.ca.plugin.adminui.utils.AppConstants.INUM; +import static io.jans.ca.plugin.adminui.utils.AppConstants.POLICY_STORE_DN; + /** * Service responsible for managing Admin UI security related operations such as - * retrieving, uploading, synchronizing and updating the Cedarling policy store. + * searching, uploading, editing, deleting and synchronizing the Cedarling policy store. * *

This service interacts with: *

    - *
  • Local policy store files
  • - *
  • Remote policy store endpoints
  • - *
  • Jans persistence layer
  • + *
  • The Jans persistence layer, where policy stores are persisted (the Cedar + * archive is held as a base64-encoded document)
  • *
  • Admin UI role and permission configuration
  • *
* - *

It also supports synchronization between Cedar policy definitions and - * Admin UI role-to-scope mappings.

+ *

It also synchronizes Admin UI role-to-scope mappings from the active policy + * store held in persistence.

*/ @Singleton @@ -68,47 +75,53 @@ public class AdminUISecurityService { @Inject AUIConfigurationService auiConfigurationService; + @Inject + ConfigurationFactory configurationFactory; + @Inject AdminUIService adminUIService; @Inject PolicyToScopeMapper policyToScopeMapper; + Tika tika = new Tika(); + private static final long MAX_ENTRY_SIZE = 3_000_000; // 3 MB private static final String TRUSTED_ISSUER_FILE = "trusted-issuers/GluuFlexAdminUI.json"; /** - * Retrieves the current policy store from the configured local file system path. - * - *

The policy store path is resolved using the following precedence:

- *
    - *
  1. Configured value in {@link AUIConfiguration#getAuiCedarlingDefaultPolicyStorePath()}
  2. - *
  3. {@link AppConstants#DEFAULT_POLICY_STORE_FILE_PATH}
  4. - *
+ * Searches persisted policy stores matching the given request. * - *

If the file exists, the method returns the binary content of the policy store - * (typically a .cjar archive). If the file does not exist, a 404 response is returned.

+ *

Each filter-assertion value is matched (as a substring) against the inum, status and + * display name, combined with OR semantics, and the results are paged and sorted according + * to the request.

* - * @return {@link GenericResponse} containing the policy store file as a byte array - * @throws ApplicationException if an unexpected error occurs while retrieving the file + * @param searchRequest the search parameters (filters, paging and sort options) + * @return a {@link PagedResult} of matching policy stores + * @throws ApplicationException (HTTP 500) if the underlying persistence query fails */ - public GenericResponse getPolicyStore() throws ApplicationException { + public PagedResult searchPolicyStores(SearchRequest searchRequest) throws ApplicationException { try { - AUIConfiguration auiConfiguration = auiConfigurationService.getAUIConfiguration(); - // Resolve path for local policy store file - String policyStorePath = Optional.ofNullable(auiConfiguration.getAuiCedarlingDefaultPolicyStorePath()) - .filter(path -> !Strings.isNullOrEmpty(path)) - .orElse(AppConstants.DEFAULT_POLICY_STORE_FILE_PATH); - Path path = Paths.get(policyStorePath); - if (!Files.exists(path)) { - throw new ApplicationException(Response.Status.NOT_FOUND.getStatusCode(), "Policy store not found."); + Filter searchFilter = null; + List filters = new ArrayList<>(); + if (searchRequest.getFilterAssertionValue() != null && !searchRequest.getFilterAssertionValue().isEmpty()) { + + for (String assertionValue : searchRequest.getFilterAssertionValue()) { + String[] targetArray = new String[]{assertionValue}; + Filter displayNameFilter = Filter.createSubstringFilter(AttributeConstants.DISPLAY_NAME, null, + targetArray, null); + Filter policyStoreIdFilter = Filter.createSubstringFilter(INUM, null, targetArray, null); + Filter statusFilter = Filter.createSubstringFilter(AppConstants.STATUS, null, targetArray, null); + filters.add(Filter.createORFilter(policyStoreIdFilter, statusFilter, displayNameFilter)); + } + searchFilter = Filter.createORFilter(filters); } - byte[] zipBytes = Files.readAllBytes(path); + log.debug("AdminUIPolicyStore searchFilter:{}", searchFilter); + return entryManager.findPagedEntries(POLICY_STORE_DN, AdminUIPolicyStore.class, searchFilter, null, + searchRequest.getSortBy(), SortOrder.getByValue(searchRequest.getSortOrder()), + searchRequest.getStartIndex(), searchRequest.getCount(), searchRequest.getMaxCount()); - return CommonUtils.createResponseWithByteArray(true, 200, "Policy store fetched successfully.", zipBytes); - } catch (ApplicationException e) { - throw e; // Re-throw ApplicationException as is } catch (Exception e) { log.error(ErrorResponse.RETRIEVE_POLICY_STORE_ERROR.getDescription(), e); throw new ApplicationException(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), ErrorResponse.RETRIEVE_POLICY_STORE_ERROR.getDescription()); @@ -116,79 +129,102 @@ public GenericResponse getPolicyStore() throws ApplicationException { } /** - * Uploads and overwrites the existing policy store file on the server. + * Validates and persists a new policy store. * - *

This method performs the following operations: - *

    - *
  • Validates the incoming request and file metadata
  • - *
  • Ensures the uploaded file has a valid .cjar extension
  • - *
  • Validates the input stream of the uploaded policy store
  • - *
  • Resolves the configured policy store path
  • - *
  • Validates the domain inside the existing policy store against the server host
  • - *
  • Creates a backup of the existing policy store file (if present)
  • - *
  • Uploads and replaces the policy store with the new file
  • - *
+ *

The request is validated (base64 decoding, zip MIME type and trusted-issuer domain + * check) and, on success, persisted with a freshly generated inum/dn, creation and last-update + * timestamps, and an {@code inactive} status. Any client-supplied inum, dn, status or + * timestamps are overwritten by the server.

* - * @param adminUIPolicyStore the {@link AdminUIPolicyStore} containing the policy store file - * and its associated metadata - * @return a {@link GenericResponse} indicating success or failure of the upload operation - * @throws ApplicationException if: - *
    - *
  • The request or document is null
  • - *
  • The file name is missing or does not have a .cjar extension
  • - *
  • The input stream is invalid or empty
  • - *
  • The policy store domain does not match the configured server host
  • - *
  • Any error occurs during validation, backup, or file upload
  • - *
+ * @param adminUIPolicyStore the policy store to create (must contain a valid base64 document) + * @return a {@link GenericResponse} indicating success + * @throws ApplicationException (HTTP 500) if validation or persistence fails */ - public GenericResponse uploadPolicyStore(AdminUIPolicyStore adminUIPolicyStore) throws ApplicationException { try { validateRequest(adminUIPolicyStore); - Document cjarDocument = adminUIPolicyStore.getDocument(); - log.info("Uploading policy-store : {}", cjarDocument.getFileName()); - - InputStream originalStream = adminUIPolicyStore.getPolicyStore(); - //copy into a variable so that it can be used later - byte[] cjarBytes = originalStream.readAllBytes(); + log.info("Uploading policy-store : {}", adminUIPolicyStore.getDisplayname()); + String inum = UUID.randomUUID().toString(); - InputStream cjarStreamForValidation = new ByteArrayInputStream(cjarBytes); - InputStream cjarStreamForUpload = new ByteArrayInputStream(cjarBytes); + adminUIPolicyStore.setInum(inum); + adminUIPolicyStore.setDn(getDnForPolicyStore(inum)); + Date now = new Date(); + adminUIPolicyStore.setCreationDate(now); + adminUIPolicyStore.setJansLastUpd(now); + adminUIPolicyStore.setJansStatus(AppConstants.STATUS_INACTIVE); + entryManager.persist(adminUIPolicyStore); - validateInputStream(cjarStreamForValidation); - - AUIConfiguration auiConfiguration = auiConfigurationService.getAUIConfiguration(); - - String policyStorePath = resolvePolicyStorePath(auiConfiguration); - - // Validate domain inside policy store - validatePolicyStoreDomain(cjarStreamForValidation, auiConfiguration.getAuiWebServerHost()); + return CommonUtils.createGenericResponse(true, 200, + "Policy store saved successfully."); - Path path = Paths.get(policyStorePath); + } catch (Exception e) { + log.error(ErrorResponse.POLICY_STORE_UPLOAD_ERROR.getDescription(), e); + throw new ApplicationException( + Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), + e.getMessage() + ); + } + } - // Backup existing file - backupExistingPolicyStore(path); - try { - // Upload new file - Files.copy(cjarStreamForUpload, path, StandardCopyOption.REPLACE_EXISTING); + /** + * Applies editable fields to an existing policy store. + * + *

Only the display name, description and status are updated; read-only fields (inum, dn, + * creation date, owner and the policy-store document) are preserved from persistence. If the + * status is being set to {@code active}, any other currently-active store is demoted to + * {@code inactive} so that at most one policy store is active at a time.

+ * + * @param inum the inum of the policy store to edit + * @param adminUIPolicyStore the source of the editable field values + * @return a {@link GenericResponse} indicating success + * @throws ApplicationException with HTTP 400 if the request or inum is invalid, HTTP 404 if + * no store exists for the inum, or HTTP 500 if the update fails + */ + public GenericResponse editPolicyStore(String inum, AdminUIPolicyStore adminUIPolicyStore) throws ApplicationException { + try { + if (adminUIPolicyStore == null || Strings.isNullOrEmpty(inum)) { + throw new ApplicationException( + Response.Status.BAD_REQUEST.getStatusCode(), + ErrorResponse.BAD_REQUEST_IN_POLICY_STORE_UPLOAD.getDescription() + ); + } - log.info("Uploaded policy-store : {}", cjarDocument.getFileName()); - } catch(Exception e) { - log.error("Upload failed. Restoring backup...", e); - restoreFromBackup(path); + AdminUIPolicyStore existing = getPolicyStoreByInum(inum); + if (existing == null) { throw new ApplicationException( - Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), - e.getMessage()); + Response.Status.NOT_FOUND.getStatusCode(), + ErrorResponse.RETRIEVE_POLICY_STORE_ERROR.getDescription() + ); + } + + // Apply only the editable fields; read-only fields (inum, dn, + // creationDate, jansUsrDN, policyStore) are preserved from persistence. + if (!Strings.isNullOrEmpty(adminUIPolicyStore.getDisplayname())) { + existing.setDisplayname(adminUIPolicyStore.getDisplayname()); + } + if (!Strings.isNullOrEmpty(adminUIPolicyStore.getDescription())) { + existing.setDescription(adminUIPolicyStore.getDescription()); } + if (!Strings.isNullOrEmpty(adminUIPolicyStore.getJansStatus())) { + // Only one policy-store may be active at a time. If this store is being + // activated, demote any other currently-active store to inactive first. + if (AppConstants.STATUS_ACTIVE.equalsIgnoreCase(adminUIPolicyStore.getJansStatus())) { + deactivateOtherActivePolicyStores(inum); + } + existing.setJansStatus(adminUIPolicyStore.getJansStatus()); + } + existing.setJansLastUpd(new Date()); + + entryManager.merge(existing); return CommonUtils.createGenericResponse(true, 200, - "Policy store overwritten successfully."); + "Policy store updated successfully."); } catch (ApplicationException e) { throw e; } catch (Exception e) { - log.error(ErrorResponse.POLICY_STORE_UPLOAD_ERROR.getDescription(), e); + log.error(ErrorResponse.POLICY_STORE_UPDATE_ERROR.getDescription(), e); throw new ApplicationException( Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), e.getMessage() @@ -196,6 +232,93 @@ public GenericResponse uploadPolicyStore(AdminUIPolicyStore adminUIPolicyStore) } } + /** + * Loads a policy store by its inum. + * + * @param inum the policy store inum + * @return the matching {@link AdminUIPolicyStore}, or {@code null} if none exists + */ + private AdminUIPolicyStore getPolicyStoreByInum(String inum) { + if (Strings.isNullOrEmpty(inum)) { + return null; + } + + try { + Filter filter = Filter.createEqualityFilter(INUM, inum); + List stores = entryManager.findEntries(POLICY_STORE_DN, AdminUIPolicyStore.class, filter, 1); + return CollectionUtils.isEmpty(stores) ? null : stores.get(0); + } catch (Exception e) { + log.error("Failed to fetch policy store for inum: '{}'", inum, e); + return null; + } + } + + /** + * Deletes the policy store identified by the given inum. + * + * @param inum the inum of the policy store to delete + * @return a {@link GenericResponse} indicating success + * @throws ApplicationException with HTTP 400 if the inum is blank, or HTTP 404 if no store + * exists for the inum + */ + public GenericResponse deletePolicyStore(String inum) throws ApplicationException { + if (Strings.isNullOrEmpty(inum)) { + throw new ApplicationException( + Response.Status.BAD_REQUEST.getStatusCode(), + ErrorResponse.BAD_REQUEST_IN_POLICY_STORE_UPLOAD.getDescription() + ); + } + + AdminUIPolicyStore existing = getPolicyStoreByInum(inum); + if (existing == null) { + throw new ApplicationException( + Response.Status.NOT_FOUND.getStatusCode(), + ErrorResponse.RETRIEVE_POLICY_STORE_ERROR.getDescription() + ); + } + log.info("delete policy-store : {}", inum); + entryManager.remove(existing); + + return CommonUtils.createGenericResponse(true, 200, + "Policy store deleted successfully."); + } + + /** + * Demotes every active policy-store other than {@code inumToKeep} to inactive, + * enforcing the "only one active policy-store" invariant. + * + * @param inumToKeep the inum of the store that is about to become active and must be left untouched + */ + private void deactivateOtherActivePolicyStores(String inumToKeep) { + Filter activeFilter = Filter.createEqualityFilter(AppConstants.STATUS, AppConstants.STATUS_ACTIVE); + List activeStores = + entryManager.findEntries(POLICY_STORE_DN, AdminUIPolicyStore.class, activeFilter); + + if (CollectionUtils.isEmpty(activeStores)) { + return; + } + for (AdminUIPolicyStore activeStore : activeStores) { + if (inumToKeep.equals(activeStore.getInum())) { + continue; + } + log.info("Deactivating previously active policy-store : {}", activeStore.getInum()); + activeStore.setJansStatus(AppConstants.STATUS_INACTIVE); + activeStore.setJansLastUpd(new Date()); + entryManager.merge(activeStore); + } + } + + /** + * Validates a policy-store upload request. + * + *

Ensures the request and its base64 document are present, that the decoded bytes are a + * zip archive, and that the trusted-issuer domain inside the archive matches the configured + * Admin UI web-server host.

+ * + * @param adminUIPolicyStore the policy store to validate + * @throws ApplicationException with HTTP 400 if the request is missing/malformed, not a zip, + * or its domain does not match the configured host + */ private void validateRequest(AdminUIPolicyStore adminUIPolicyStore) throws ApplicationException { if (adminUIPolicyStore == null) { log.error(ErrorResponse.BAD_REQUEST_IN_POLICY_STORE_UPLOAD.getDescription()); @@ -205,47 +328,57 @@ private void validateRequest(AdminUIPolicyStore adminUIPolicyStore) throws Appli ); } - Document document = adminUIPolicyStore.getDocument(); - if (document == null || document.getFileName() == null) { - log.error("Document details not provided."); + String base64PolicyStore = adminUIPolicyStore.getPolicyStore(); + if (Strings.isNullOrEmpty(base64PolicyStore)) { + log.error("PolicyStore is null."); throw new ApplicationException( Response.Status.BAD_REQUEST.getStatusCode(), ErrorResponse.BAD_REQUEST_IN_POLICY_STORE_UPLOAD.getDescription() ); } - if (!document.getFileName().endsWith(".cjar")) { + byte[] zipBytes; + try { + zipBytes = Base64.getDecoder().decode(base64PolicyStore); + } catch (IllegalArgumentException e) { + log.error(ErrorResponse.BAD_REQUEST_IN_POLICY_STORE_UPLOAD.getDescription(), e); + throw new ApplicationException( + Response.Status.BAD_REQUEST.getStatusCode(), + ErrorResponse.BAD_REQUEST_IN_POLICY_STORE_UPLOAD.getDescription() + ); + } + + String mimeType = tika.detect(zipBytes); + if (Strings.isNullOrEmpty(mimeType) || !mimeType.equalsIgnoreCase("application/zip")) { log.error(ErrorResponse.UNSUPPORTED_POLICY_STORE_EXTENSION.getDescription()); throw new ApplicationException( Response.Status.BAD_REQUEST.getStatusCode(), ErrorResponse.UNSUPPORTED_POLICY_STORE_EXTENSION.getDescription() ); } - } - private void validateInputStream(InputStream inputStream) throws ApplicationException { - try { - if (inputStream == null || inputStream.available() <= 0) { - log.error(ErrorResponse.BAD_REQUEST_IN_POLICY_STORE_UPLOAD.getDescription()); - throw new ApplicationException( - Response.Status.BAD_REQUEST.getStatusCode(), - ErrorResponse.BAD_REQUEST_IN_POLICY_STORE_UPLOAD.getDescription() - ); - } - } catch (IOException e) { + try (InputStream cjarStream = new ByteArrayInputStream(zipBytes)) { + AUIConfiguration auiConfiguration = auiConfigurationService.getAUIConfiguration(); + validatePolicyStoreDomain(cjarStream, auiConfiguration.getAuiWebServerHost()); + } catch (ApplicationException e) { + throw e; + } catch (URISyntaxException | IOException e) { throw new ApplicationException( Response.Status.BAD_REQUEST.getStatusCode(), - "Error in reading policy-store. Invalid input stream" + e.getMessage() ); } } - private String resolvePolicyStorePath(AUIConfiguration config) { - return Optional.ofNullable(config.getAuiCedarlingDefaultPolicyStorePath()) - .filter(path -> !Strings.isNullOrEmpty(path)) - .orElse(AppConstants.DEFAULT_POLICY_STORE_FILE_PATH); - } - + /** + * Verifies that the trusted-issuer domain inside the policy-store archive matches the + * host of the configured Admin UI web-server URL. + * + * @param cjarStream the policy-store archive stream to inspect + * @param webServerHost the configured Admin UI web-server URL whose host must match + * @throws ApplicationException with HTTP 400 if the domains do not match + * @throws URISyntaxException if {@code webServerHost} is not a valid URI + */ private void validatePolicyStoreDomain(InputStream cjarStream, String webServerHost) throws ApplicationException, URISyntaxException { @@ -259,32 +392,13 @@ private void validatePolicyStoreDomain(InputStream cjarStream, String webServerH } } - private void backupExistingPolicyStore(Path path) throws IOException { - if (Files.exists(path)) { - Path backupPath = Paths.get(path.toString() + ".bak"); - Files.move(path, backupPath, StandardCopyOption.REPLACE_EXISTING); - } - } - - private void restoreFromBackup(Path path) { - try { - Path backupPath = Paths.get(path.toString() + ".bak"); - if (Files.exists(backupPath)) { - Files.move(backupPath, path, StandardCopyOption.REPLACE_EXISTING); - log.info("Restored policy store from backup"); - } - } catch (IOException e) { - log.error("Failed to restore policy store from backup", e); - } - } - /** - * Synchronizes Admin UI role-to-scope mappings using the currently configured Cedar policy store. + * Synchronizes Admin UI role-to-scope mappings using the active Cedar policy store stored in the database. * *

The synchronization process includes:

*
    *
  1. Retrieving resource-to-scope mappings from persistence
  2. - *
  3. Parsing the Cedar policy store archive (.cjar)
  4. + *
  5. Fetching the active policy store from the database and parsing its Cedar archive (.cjar)
  6. *
  7. Deriving principal-to-scope mappings from policies
  8. *
  9. Generating Admin UI roles from the principals
  10. *
  11. Generating role-permission mappings
  12. @@ -315,19 +429,16 @@ public GenericResponse syncRoleScopeMapping() throws ApplicationException { throw new ApplicationException(Response.Status.BAD_REQUEST.getStatusCode(), "Invalid input data for synchronization"); } - AUIConfiguration auiConfiguration = auiConfigurationService.getAUIConfiguration(); - String policyStorePath = Optional.ofNullable(auiConfiguration.getAuiCedarlingDefaultPolicyStorePath()) - .filter(path -> !Strings.isNullOrEmpty(path)) - .orElse(AppConstants.DEFAULT_POLICY_STORE_FILE_PATH); - Map> principalsToScopesMap; - try (ZipFile zipFile = new ZipFile(policyStorePath)) { - principalsToScopesMap = policyToScopeMapper.processZipFile(zipFile, resourceScopesJson); - } catch (Exception e) { - log.error(ErrorResponse.ERROR_IN_POLICY_STORE.getDescription(), e); - throw new ApplicationException(Response.Status.BAD_REQUEST.getStatusCode(), - ErrorResponse.ERROR_IN_POLICY_STORE.getDescription()); + // Fetch the active policy-store from the database (its document is stored as base64). + AdminUIPolicyStore activePolicyStore = getActivePolicyStore(); + if (activePolicyStore == null || Strings.isNullOrEmpty(activePolicyStore.getPolicyStore())) { + log.error(ErrorResponse.NO_ACTIVE_POLICY_STORE_FOUND.getDescription()); + throw new ApplicationException(Response.Status.NOT_FOUND.getStatusCode(), + ErrorResponse.NO_ACTIVE_POLICY_STORE_FOUND.getDescription()); } + Map> principalsToScopesMap = mapPrincipalsToScopes(activePolicyStore, resourceScopesJson); + // Validate mapping results if (principalsToScopesMap.isEmpty()) { log.warn("No role-to-scope mappings found during synchronization"); @@ -355,6 +466,60 @@ public GenericResponse syncRoleScopeMapping() throws ApplicationException { } } + /** + * Retrieves the single active policy-store from persistence. + * + * @return the active {@link AdminUIPolicyStore}, or {@code null} if none is active + */ + private AdminUIPolicyStore getActivePolicyStore() { + Filter activeFilter = Filter.createEqualityFilter(AppConstants.STATUS, AppConstants.STATUS_ACTIVE); + List activeStores = + entryManager.findEntries(POLICY_STORE_DN, AdminUIPolicyStore.class, activeFilter); + + if (CollectionUtils.isEmpty(activeStores)) { + return null; + } + if (activeStores.size() > 1) { + log.warn("Found {} active policy-stores; using the first one.", activeStores.size()); + } + return activeStores.get(0); + } + + /** + * Decodes the base64 policy-store document into a temporary .cjar archive and derives + * principal-to-scope mappings from it. The temporary file is always removed afterwards. + * + * @param policyStore the active policy-store whose document should be parsed + * @param resourceScopesJson the resource-to-scope mappings used during parsing + * @return principal-to-scope mappings derived from the policy-store + * @throws ApplicationException if the document cannot be decoded or parsed + */ + private Map> mapPrincipalsToScopes(AdminUIPolicyStore policyStore, JsonNode resourceScopesJson) + throws ApplicationException { + Path tempFile = null; + try { + byte[] zipBytes = Base64.getDecoder().decode(policyStore.getPolicyStore()); + tempFile = Files.createTempFile("adminui-policy-store-", ".cjar"); + Files.write(tempFile, zipBytes); + + try (ZipFile zipFile = new ZipFile(tempFile.toFile())) { + return policyToScopeMapper.processZipFile(zipFile, resourceScopesJson); + } + } catch (Exception e) { + log.error(ErrorResponse.ERROR_IN_POLICY_STORE.getDescription(), e); + throw new ApplicationException(Response.Status.BAD_REQUEST.getStatusCode(), + ErrorResponse.ERROR_IN_POLICY_STORE.getDescription()); + } finally { + if (tempFile != null) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException e) { + log.warn("Failed to delete temporary policy-store file: {}", tempFile, e); + } + } + } + } + /** * Extracted method for creating AdminRole objects */ @@ -402,6 +567,16 @@ private List removeDuplicatePermissions(List 0 + ? configurationFactory.getApiAppConfiguration().getMaxCount() + : ApiConstants.DEFAULT_MAX_COUNT); + } } diff --git a/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/utils/AppConstants.java b/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/utils/AppConstants.java index 20b453de5c5..67d6121cc88 100644 --- a/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/utils/AppConstants.java +++ b/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/utils/AppConstants.java @@ -4,6 +4,7 @@ public interface AppConstants { public static final String ADMIN_UI_CONFIG_DN = "ou=admin-ui,ou=configuration,o=jans"; public static final String ADMIN_UI_RESOURCE_SCOPES_MAPPING_DN = "ou=adminUIResourceScopesMapping,ou=admin-ui,o=jans"; public static final String WEBHOOK_DN = "ou=auiWebhooks,ou=admin-ui,o=jans"; + public static final String POLICY_STORE_DN = "ou=adminUIPolicyStore,ou=admin-ui,o=jans"; public static final String ADMIN_UI_FEATURES_DN = "ou=auiFeatures,ou=admin-ui,o=jans"; public static final String ADS_CONFIG_DN = "ou=agama-developer-studio,ou=configuration,o=jans"; public static final String DEFAULT_POLICY_STORE_FILE_PATH = "./custom/config/adminUI/policy-store.cjar"; @@ -21,6 +22,9 @@ public interface AppConstants { //fields name public static final String WEBHOOK_ID = "webhookId"; public static final String INUM = "inum"; + public static final String STATUS = "jansStatus"; + public static final String STATUS_ACTIVE = "active"; + public static final String STATUS_INACTIVE = "inactive"; public static final String ADMIN_UI_FEATURE_ID = "featureId"; public static final String AUTHORIZATION = "Authorization"; public static final String CONTENT_TYPE = "Content-Type"; diff --git a/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/utils/ErrorResponse.java b/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/utils/ErrorResponse.java index ab69c1d63ff..16289051f58 100644 --- a/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/utils/ErrorResponse.java +++ b/jans-config-api/plugins/admin-ui-plugin/src/main/java/io/jans/ca/plugin/adminui/utils/ErrorResponse.java @@ -76,10 +76,15 @@ public enum ErrorResponse { ADMINUI_SESSION_REMOVE_ERROR("Error in removing Admin UI session"), ORG_ID_CLAIM_NOT_FOUND("org_id claim not found in jwt"), POLICY_STORE_UPLOAD_ERROR("Error in uploading policy-store"), + POLICY_STORE_UPDATE_ERROR("Error in updating policy-store"), + POLICY_STORE_DELETE_ERROR("Error in deleting policy-store"), + POLICY_STORE_GET_ERROR("Error in fetching policy-store"), + POLICY_STORE_NOT_EXIST("Policy store does not exist"), BAD_REQUEST_IN_POLICY_STORE_UPLOAD("Bad Request: No Policy Store file provided"), - UNSUPPORTED_POLICY_STORE_EXTENSION("Bad Request: The Policy Store extension is not .cjar and is not supported"), + UNSUPPORTED_POLICY_STORE_EXTENSION("Bad Request: The Policy Store extension is not .cjar or of application/zip mime and is not supported"), POLICY_STORE_DOMAIN_NOT_MATCHING("The hostname of the configuration_endpoint in policy-store does not matches with hostname of OpenID Provider."), - ERROR_IN_POLICY_STORE("Bad Request: Error in reading Policy Store file") + ERROR_IN_POLICY_STORE("Bad Request: Error in reading Policy Store file"), + NO_ACTIVE_POLICY_STORE_FOUND("No active policy-store found. Activate a policy-store before syncing role-to-scope mappings.") ; private final String description; diff --git a/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/create-policy-store-request.json b/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/create-policy-store-request.json new file mode 100644 index 00000000000..498d4030e84 --- /dev/null +++ b/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/create-policy-store-request.json @@ -0,0 +1,6 @@ +{ + "displayname": "Admin UI Cedarling Policy Store", + "description": "Cedarling policy store used to derive Admin UI role-to-scope mappings", + "policyStore": "UEsDBBQACAgIAABb...==", + "jansStatus": "active" +} diff --git a/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/create-policy-store-response.json b/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/create-policy-store-response.json new file mode 100644 index 00000000000..97ee11923e7 --- /dev/null +++ b/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/create-policy-store-response.json @@ -0,0 +1,5 @@ +{ + "success": true, + "responseCode": 200, + "responseMessage": "Policy store saved successfully." +} diff --git a/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/delete-policy-store-response.json b/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/delete-policy-store-response.json new file mode 100644 index 00000000000..4445f855319 --- /dev/null +++ b/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/delete-policy-store-response.json @@ -0,0 +1,5 @@ +{ + "success": true, + "responseCode": 200, + "responseMessage": "Policy store deleted successfully." +} diff --git a/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/edit-policy-store-request.json b/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/edit-policy-store-request.json new file mode 100644 index 00000000000..b636b569d2a --- /dev/null +++ b/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/edit-policy-store-request.json @@ -0,0 +1,5 @@ +{ + "displayname": "Admin UI Cedarling Policy Store (updated)", + "description": "Updated description for the Admin UI policy store", + "jansStatus": "inactive" +} diff --git a/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/edit-policy-store-response.json b/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/edit-policy-store-response.json new file mode 100644 index 00000000000..4bc24d4a351 --- /dev/null +++ b/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/edit-policy-store-response.json @@ -0,0 +1,5 @@ +{ + "success": true, + "responseCode": 200, + "responseMessage": "Policy store updated successfully." +} diff --git a/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/get-all-policy-store-response.json b/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/get-all-policy-store-response.json new file mode 100644 index 00000000000..6ddebfe05ab --- /dev/null +++ b/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/get-all-policy-store-response.json @@ -0,0 +1,18 @@ +{ + "start": 0, + "totalEntriesCount": 1, + "entriesCount": 1, + "entries": [ + { + "dn": "inum=e1a2b3c4-1234-5678-9abc-def012345678,ou=policy-stores,ou=admin-ui,o=jans", + "inum": "e1a2b3c4-1234-5678-9abc-def012345678", + "displayname": "Admin UI Cedarling Policy Store", + "description": "Cedarling policy store used to derive Admin UI role-to-scope mappings", + "policyStore": "UEsDBBQACAgIAABb...==", + "jansUsrDN": "inum=abc123,ou=people,o=jans", + "jansStatus": "active", + "creationDate": "2026-07-14T10:15:30.000Z", + "jansLastUpd": "2026-07-14T11:20:45.000Z" + } + ] +} diff --git a/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/sync-role-scopes-mapping-response.json b/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/sync-role-scopes-mapping-response.json new file mode 100644 index 00000000000..6243aeed2b4 --- /dev/null +++ b/jans-config-api/plugins/admin-ui-plugin/src/main/resources/example/adminui/security/sync-role-scopes-mapping-response.json @@ -0,0 +1,5 @@ +{ + "success": true, + "responseCode": 200, + "responseMessage": "Sync of role-to-scope mappings from the policy store completed successfully." +} diff --git a/jans-config-api/plugins/admin-ui-plugin/src/test/java/io/jans/ca/plugin/adminui/test/services/AdminUISecurityServiceTest.java b/jans-config-api/plugins/admin-ui-plugin/src/test/java/io/jans/ca/plugin/adminui/test/services/AdminUISecurityServiceTest.java new file mode 100644 index 00000000000..41e8a6f00b5 --- /dev/null +++ b/jans-config-api/plugins/admin-ui-plugin/src/test/java/io/jans/ca/plugin/adminui/test/services/AdminUISecurityServiceTest.java @@ -0,0 +1,403 @@ +package io.jans.ca.plugin.adminui.test.services; + +import io.jans.ca.plugin.adminui.model.adminui.AdminUIPolicyStore; +import io.jans.ca.plugin.adminui.model.auth.GenericResponse; +import io.jans.ca.plugin.adminui.model.exception.ApplicationException; +import io.jans.ca.plugin.adminui.service.adminui.AdminUISecurityService; +import io.jans.ca.plugin.adminui.service.config.AUIConfigurationService; +import io.jans.ca.plugin.adminui.utils.AppConstants; +import io.jans.ca.plugin.adminui.utils.ErrorResponse; +import io.jans.configapi.configuration.ConfigurationFactory; +import io.jans.configapi.model.configuration.ApiAppConfiguration; +import io.jans.model.SearchRequest; +import io.jans.orm.PersistenceEntryManager; +import io.jans.orm.model.PagedResult; +import io.jans.orm.model.SortOrder; +import io.jans.orm.search.filter.Filter; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.testng.MockitoTestNGListener; +import org.slf4j.Logger; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Listeners; +import org.testng.annotations.Test; + +import jakarta.ws.rs.core.Response; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +/** + * Unit tests for {@link AdminUISecurityService} covering the methods invoked + * from {@code AdminUISecurityResource}: searchPolicyStores, uploadPolicyStore, + * editPolicyStore, deletePolicyStore, syncRoleScopeMapping and getRecordMaxCount. + */ +@Listeners(MockitoTestNGListener.class) +public class AdminUISecurityServiceTest { + + private static final String INUM = "1234-5678"; + + @Mock + private Logger log; + @Mock + private PersistenceEntryManager entryManager; + @Mock + private AUIConfigurationService auiConfigurationService; + @Mock + private ConfigurationFactory configurationFactory; + @Mock + private io.jans.ca.plugin.adminui.service.adminui.AdminUIService adminUIService; + @Mock + private io.jans.ca.plugin.adminui.utils.security.PolicyToScopeMapper policyToScopeMapper; + + @InjectMocks + private AdminUISecurityService adminUISecurityService; + + private static final String WEB_SERVER_HOST = "https://test-jans.io"; + private static final String POLICY_STORE_FIXTURE = "/json/adminui/test-policy-store.cjar"; + + @BeforeMethod + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + /** + * Loads the packaged {@code .cjar} test fixture and returns it base64-encoded, + * as stored in the {@code policyStore} document field. + */ + private String loadPolicyStoreBase64() throws java.io.IOException { + try (java.io.InputStream is = getClass().getResourceAsStream(POLICY_STORE_FIXTURE)) { + assertNotNull(is, "Missing test fixture: " + POLICY_STORE_FIXTURE); + return java.util.Base64.getEncoder().encodeToString(is.readAllBytes()); + } + } + + // --------------------------------------------------------------------- + // searchPolicyStores + // --------------------------------------------------------------------- + + @Test + public void testSearchPolicyStores_success() throws ApplicationException { + SearchRequest searchRequest = new SearchRequest(); + searchRequest.setSortBy(AppConstants.INUM); + searchRequest.setSortOrder(SortOrder.ASCENDING.getValue()); + searchRequest.setStartIndex(0); + searchRequest.setCount(10); + searchRequest.setMaxCount(100); + + PagedResult expected = new PagedResult<>(); + when(entryManager.findPagedEntries(anyString(), eq(AdminUIPolicyStore.class), any(), + isNull(), anyString(), any(SortOrder.class), anyInt(), anyInt(), anyInt())) + .thenReturn(expected); + + PagedResult result = adminUISecurityService.searchPolicyStores(searchRequest); + + assertSame(result, expected); + } + + @Test + public void testSearchPolicyStores_persistenceFailureWrapped() { + SearchRequest searchRequest = new SearchRequest(); + searchRequest.setSortBy(AppConstants.INUM); + searchRequest.setSortOrder(SortOrder.ASCENDING.getValue()); + lenient().when(entryManager.findPagedEntries(anyString(), any(), any(), any(), any(), + any(), anyInt(), anyInt(), anyInt())) + .thenThrow(new RuntimeException("db down")); + + ApplicationException ex = expectThrows(ApplicationException.class, + () -> adminUISecurityService.searchPolicyStores(searchRequest)); + assertEquals(ex.getErrorCode(), Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()); + } + + // --------------------------------------------------------------------- + // uploadPolicyStore + // --------------------------------------------------------------------- + + @Test + public void testUploadPolicyStore_nullRequestThrowsBadRequest() { + // validateRequest fails -> wrapped as INTERNAL_SERVER_ERROR by uploadPolicyStore + ApplicationException ex = expectThrows(ApplicationException.class, + () -> adminUISecurityService.uploadPolicyStore(null)); + assertEquals(ex.getErrorCode(), Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()); + verify(entryManager, never()).persist(any()); + } + + @Test + public void testUploadPolicyStore_missingPolicyStoreDocument() { + AdminUIPolicyStore store = new AdminUIPolicyStore(); + store.setDisplayname("test"); + // policyStore document is null -> validation fails + + ApplicationException ex = expectThrows(ApplicationException.class, + () -> adminUISecurityService.uploadPolicyStore(store)); + assertEquals(ex.getErrorCode(), Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()); + verify(entryManager, never()).persist(any()); + } + + @Test + public void testUploadPolicyStore_success() throws Exception { + io.jans.configapi.core.model.adminui.AUIConfiguration auiConfiguration = + mock(io.jans.configapi.core.model.adminui.AUIConfiguration.class); + when(auiConfigurationService.getAUIConfiguration()).thenReturn(auiConfiguration); + when(auiConfiguration.getAuiWebServerHost()).thenReturn(WEB_SERVER_HOST); + + AdminUIPolicyStore store = new AdminUIPolicyStore(); + store.setDisplayname("test-store"); + store.setPolicyStore(loadPolicyStoreBase64()); + + GenericResponse response = adminUISecurityService.uploadPolicyStore(store); + + assertTrue(response.isSuccess()); + assertEquals(response.getResponseCode(), 200); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdminUIPolicyStore.class); + verify(entryManager).persist(captor.capture()); + AdminUIPolicyStore persisted = captor.getValue(); + assertNotNull(persisted.getInum()); + assertEquals(persisted.getDn(), adminUISecurityService.getDnForPolicyStore(persisted.getInum())); + assertEquals(persisted.getJansStatus(), AppConstants.STATUS_INACTIVE); + assertNotNull(persisted.getCreationDate()); + } + + @Test + public void testUploadPolicyStore_domainMismatchRejected() throws Exception { + io.jans.configapi.core.model.adminui.AUIConfiguration auiConfiguration = + mock(io.jans.configapi.core.model.adminui.AUIConfiguration.class); + when(auiConfigurationService.getAUIConfiguration()).thenReturn(auiConfiguration); + when(auiConfiguration.getAuiWebServerHost()).thenReturn("https://someone-else.example.com"); + + AdminUIPolicyStore store = new AdminUIPolicyStore(); + store.setDisplayname("test-store"); + store.setPolicyStore(loadPolicyStoreBase64()); + + // domain does not match the fixture's trusted-issuer host -> wrapped as 500 by uploadPolicyStore + expectThrows(ApplicationException.class, () -> adminUISecurityService.uploadPolicyStore(store)); + verify(entryManager, never()).persist(any()); + } + + // --------------------------------------------------------------------- + // editPolicyStore + // --------------------------------------------------------------------- + + @Test + public void testEditPolicyStore_nullRequestReturnsBadRequest() { + ApplicationException ex = expectThrows(ApplicationException.class, + () -> adminUISecurityService.editPolicyStore(INUM, null)); + assertEquals(ex.getErrorCode(), Response.Status.BAD_REQUEST.getStatusCode()); + } + + @Test + public void testEditPolicyStore_blankInumReturnsBadRequest() { + ApplicationException ex = expectThrows(ApplicationException.class, + () -> adminUISecurityService.editPolicyStore("", new AdminUIPolicyStore())); + assertEquals(ex.getErrorCode(), Response.Status.BAD_REQUEST.getStatusCode()); + } + + @Test + public void testEditPolicyStore_notFound() { + when(entryManager.find(eq(AdminUIPolicyStore.class), anyString())).thenReturn(null); + + ApplicationException ex = expectThrows(ApplicationException.class, + () -> adminUISecurityService.editPolicyStore(INUM, new AdminUIPolicyStore())); + assertEquals(ex.getErrorCode(), Response.Status.NOT_FOUND.getStatusCode()); + verify(entryManager, never()).merge(any()); + } + + @Test + public void testEditPolicyStore_success() throws ApplicationException { + AdminUIPolicyStore existing = new AdminUIPolicyStore(); + existing.setInum(INUM); + existing.setDisplayname("old"); + when(entryManager.find(eq(AdminUIPolicyStore.class), anyString())).thenReturn(existing); + + AdminUIPolicyStore update = new AdminUIPolicyStore(); + update.setDisplayname("new-name"); + update.setDescription("new-desc"); + update.setJansStatus(AppConstants.STATUS_ACTIVE); + + GenericResponse response = adminUISecurityService.editPolicyStore(INUM, update); + + assertTrue(response.isSuccess()); + assertEquals(response.getResponseCode(), 200); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdminUIPolicyStore.class); + verify(entryManager).merge(captor.capture()); + AdminUIPolicyStore merged = captor.getValue(); + assertEquals(merged.getDisplayname(), "new-name"); + assertEquals(merged.getDescription(), "new-desc"); + assertEquals(merged.getJansStatus(), AppConstants.STATUS_ACTIVE); + // read-only field preserved + assertEquals(merged.getInum(), INUM); + } + + @Test + public void testEditPolicyStore_activatingDemotesOtherActiveStore() throws ApplicationException { + AdminUIPolicyStore existing = new AdminUIPolicyStore(); + existing.setInum(INUM); + existing.setJansStatus(AppConstants.STATUS_INACTIVE); + when(entryManager.find(eq(AdminUIPolicyStore.class), anyString())).thenReturn(existing); + + // Another store is currently active and must be demoted. + AdminUIPolicyStore otherActive = new AdminUIPolicyStore(); + otherActive.setInum("other-inum"); + otherActive.setJansStatus(AppConstants.STATUS_ACTIVE); + when(entryManager.findEntries(anyString(), eq(AdminUIPolicyStore.class), any(Filter.class))) + .thenReturn(java.util.List.of(otherActive)); + + AdminUIPolicyStore update = new AdminUIPolicyStore(); + update.setJansStatus(AppConstants.STATUS_ACTIVE); + + GenericResponse response = adminUISecurityService.editPolicyStore(INUM, update); + + assertTrue(response.isSuccess()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdminUIPolicyStore.class); + verify(entryManager, times(2)).merge(captor.capture()); + java.util.List merged = captor.getAllValues(); + // the previously-active store was demoted... + AdminUIPolicyStore demoted = merged.stream() + .filter(s -> "other-inum".equals(s.getInum())).findFirst().orElseThrow(); + assertEquals(demoted.getJansStatus(), AppConstants.STATUS_INACTIVE); + // ...and the edited store is now active + AdminUIPolicyStore activated = merged.stream() + .filter(s -> INUM.equals(s.getInum())).findFirst().orElseThrow(); + assertEquals(activated.getJansStatus(), AppConstants.STATUS_ACTIVE); + } + + @Test + public void testEditPolicyStore_activatingSelfDoesNotDemoteItself() throws ApplicationException { + AdminUIPolicyStore existing = new AdminUIPolicyStore(); + existing.setInum(INUM); + existing.setJansStatus(AppConstants.STATUS_ACTIVE); + when(entryManager.find(eq(AdminUIPolicyStore.class), anyString())).thenReturn(existing); + + // The only active store is the one being edited; it must not be demoted. + AdminUIPolicyStore self = new AdminUIPolicyStore(); + self.setInum(INUM); + self.setJansStatus(AppConstants.STATUS_ACTIVE); + when(entryManager.findEntries(anyString(), eq(AdminUIPolicyStore.class), any(Filter.class))) + .thenReturn(java.util.List.of(self)); + + AdminUIPolicyStore update = new AdminUIPolicyStore(); + update.setJansStatus(AppConstants.STATUS_ACTIVE); + + GenericResponse response = adminUISecurityService.editPolicyStore(INUM, update); + + assertTrue(response.isSuccess()); + // only the edited store is merged (once); nothing gets demoted + ArgumentCaptor captor = ArgumentCaptor.forClass(AdminUIPolicyStore.class); + verify(entryManager, times(1)).merge(captor.capture()); + assertEquals(captor.getValue().getJansStatus(), AppConstants.STATUS_ACTIVE); + } + + // --------------------------------------------------------------------- + // deletePolicyStore + // --------------------------------------------------------------------- + + @Test + public void testDeletePolicyStore_blankInumReturnsBadRequest() { + ApplicationException ex = expectThrows(ApplicationException.class, + () -> adminUISecurityService.deletePolicyStore("")); + assertEquals(ex.getErrorCode(), Response.Status.BAD_REQUEST.getStatusCode()); + verify(entryManager, never()).remove(any()); + } + + @Test + public void testDeletePolicyStore_notFound() { + when(entryManager.find(eq(AdminUIPolicyStore.class), anyString())).thenReturn(null); + + ApplicationException ex = expectThrows(ApplicationException.class, + () -> adminUISecurityService.deletePolicyStore(INUM)); + assertEquals(ex.getErrorCode(), Response.Status.NOT_FOUND.getStatusCode()); + verify(entryManager, never()).remove(any()); + } + + @Test + public void testDeletePolicyStore_success() throws ApplicationException { + AdminUIPolicyStore existing = new AdminUIPolicyStore(); + existing.setInum(INUM); + when(entryManager.find(eq(AdminUIPolicyStore.class), anyString())).thenReturn(existing); + + GenericResponse response = adminUISecurityService.deletePolicyStore(INUM); + + assertTrue(response.isSuccess()); + assertEquals(response.getResponseCode(), 200); + verify(entryManager).remove(existing); + } + + // --------------------------------------------------------------------- + // syncRoleScopeMapping + // --------------------------------------------------------------------- + + @Test + public void testSyncRoleScopeMapping_noActivePolicyStore() { + // resource-scope mappings lookup returns empty list (non-null -> JSON node is created) + when(entryManager.findEntries(anyString(), any(), any(Filter.class))) + .thenReturn(java.util.Collections.emptyList()); + + ApplicationException ex = expectThrows(ApplicationException.class, + () -> adminUISecurityService.syncRoleScopeMapping()); + assertEquals(ex.getErrorCode(), Response.Status.NOT_FOUND.getStatusCode()); + assertEquals(ex.getMessage(), ErrorResponse.NO_ACTIVE_POLICY_STORE_FOUND.getDescription()); + } + + @Test + public void testSyncRoleScopeMapping_success() throws Exception { + // resource-scope mappings lookup (presence filter) + when(entryManager.findEntries(anyString(), + eq(io.jans.ca.plugin.adminui.model.adminui.AdminUIResourceScopesMapping.class), + any(Filter.class))) + .thenReturn(java.util.Collections.emptyList()); + + // active policy-store lookup (equality filter) -> one active store with the fixture document + AdminUIPolicyStore activeStore = new AdminUIPolicyStore(); + activeStore.setInum(INUM); + activeStore.setJansStatus(AppConstants.STATUS_ACTIVE); + activeStore.setPolicyStore(loadPolicyStoreBase64()); + when(entryManager.findEntries(anyString(), eq(AdminUIPolicyStore.class), any(Filter.class))) + .thenReturn(java.util.List.of(activeStore)); + + // policy-store parsing is delegated to the mapper (mocked): return a role with a duplicate scope + java.util.Map> mapped = new java.util.HashMap<>(); + mapped.put("adminRole", new java.util.LinkedHashSet<>(java.util.List.of("scope-read", "scope-write"))); + when(policyToScopeMapper.processZipFile(any(), any())).thenReturn(mapped); + + GenericResponse response = adminUISecurityService.syncRoleScopeMapping(); + + assertTrue(response.isSuccess()); + assertEquals(response.getResponseCode(), 200); + verify(policyToScopeMapper).processZipFile(any(), any()); + verify(adminUIService).resetRoles(any()); + verify(adminUIService).resetPermissionsToRole(any()); + } + + // --------------------------------------------------------------------- + // getRecordMaxCount + // --------------------------------------------------------------------- + + @Test + public void testGetRecordMaxCount_usesConfiguredValue() { + ApiAppConfiguration apiAppConfiguration = mock(ApiAppConfiguration.class); + when(configurationFactory.getApiAppConfiguration()).thenReturn(apiAppConfiguration); + when(apiAppConfiguration.getMaxCount()).thenReturn(250); + + assertEquals(adminUISecurityService.getRecordMaxCount(), 250); + } + + @Test + public void testGetRecordMaxCount_fallsBackToDefaultWhenNotPositive() { + ApiAppConfiguration apiAppConfiguration = mock(ApiAppConfiguration.class); + when(configurationFactory.getApiAppConfiguration()).thenReturn(apiAppConfiguration); + when(apiAppConfiguration.getMaxCount()).thenReturn(0); + + assertEquals(adminUISecurityService.getRecordMaxCount(), + io.jans.configapi.util.ApiConstants.DEFAULT_MAX_COUNT); + } +} diff --git a/jans-config-api/plugins/admin-ui-plugin/src/test/java/io/jans/ca/plugin/adminui/test/services/LicenseServiceTest.java b/jans-config-api/plugins/admin-ui-plugin/src/test/java/io/jans/ca/plugin/adminui/test/services/LicenseServiceTest.java index 76eda592d9f..4b9879cf650 100644 --- a/jans-config-api/plugins/admin-ui-plugin/src/test/java/io/jans/ca/plugin/adminui/test/services/LicenseServiceTest.java +++ b/jans-config-api/plugins/admin-ui-plugin/src/test/java/io/jans/ca/plugin/adminui/test/services/LicenseServiceTest.java @@ -55,6 +55,10 @@ public void setUp() { lenient().when(adminConf.getMainSettings()).thenReturn(mock(MainSettings.class)); lenient().when(adminConf.getMainSettings().getLicenseConfig()).thenReturn(licenseConfig); + // The service also reads the in-memory license configuration; stub that path so + // auiConfiguration / licenseConfiguration are never null during validation. + lenient().when(auiConfigurationService.getAUIConfiguration()).thenReturn(auiConfiguration); + lenient().when(auiConfiguration.getLicenseConfiguration()).thenReturn(licenseConfiguration); } @Test @@ -98,6 +102,10 @@ void testValidateLicenseConfiguration_ValidConfig() { when(licenseConfig.getOidcClient()).thenReturn(new OIDCClientSettings("test-host", "test-client-id", "test-client-secret")); when(licenseConfig.getIntervalForSyncLicenseDetailsInDays()).thenReturn(30L); + // In-memory license details: not expired and recently synced, so no re-sync is triggered. + lenient().when(licenseConfiguration.getLicenseValidUpto()).thenReturn(LocalDate.now().plusDays(30).toString()); + lenient().when(licenseConfiguration.getLicenseDetailsLastUpdatedOn()).thenReturn(LocalDate.now().minusDays(5).toString()); + GenericResponse response = licenseDetailsService.validateLicenseConfiguration(); assertTrue(response.isSuccess()); assertEquals(200, response.getResponseCode()); diff --git a/jans-config-api/plugins/admin-ui-plugin/src/test/resources/json/adminui/test-policy-store.cjar b/jans-config-api/plugins/admin-ui-plugin/src/test/resources/json/adminui/test-policy-store.cjar new file mode 100644 index 00000000000..a344dc5001c Binary files /dev/null and b/jans-config-api/plugins/admin-ui-plugin/src/test/resources/json/adminui/test-policy-store.cjar differ diff --git a/jans-config-api/plugins/docs/jans-admin-ui-plugin-swagger.yaml b/jans-config-api/plugins/docs/jans-admin-ui-plugin-swagger.yaml index da05bc82c7c..953fbd186aa 100644 --- a/jans-config-api/plugins/docs/jans-admin-ui-plugin-swagger.yaml +++ b/jans-config-api/plugins/docs/jans-admin-ui-plugin-swagger.yaml @@ -731,6 +731,50 @@ paths: summary: Get Admin UI policy store description: Get Admin UI policy store operationId: get-adminui-policy-store + parameters: + - name: limit + in: query + description: Search size - max size of the results to return + schema: + type: integer + format: int32 + default: 50 + - name: pattern + in: query + description: Search pattern + schema: + type: string + default: "" + - name: startIndex + in: query + description: The 1-based index of the first query result + schema: + type: integer + format: int32 + default: 0 + - name: sortBy + in: query + description: Attribute whose value will be used to order the returned response + schema: + type: string + default: inum + - name: sortOrder + in: query + description: Order in which the sortBy param is applied. Allowed values are + "ascending" and "descending" + schema: + type: string + default: ascending + - name: fieldValuePair + in: query + description: Field and value pair for seraching + schema: + type: string + default: "" + examples: + Field value example: + description: Field value example + value: "scopeType=spontaneous,defaultScope=true" responses: "200": description: Ok @@ -739,7 +783,29 @@ paths: schema: type: array items: - $ref: "#/components/schemas/GenericResponse" + $ref: "#/components/schemas/AdminUIPolicyStore" + examples: + Response json example: + description: Response json example + value: | + { + "start": 0, + "totalEntriesCount": 1, + "entriesCount": 1, + "entries": [ + { + "dn": "inum=e1a2b3c4-1234-5678-9abc-def012345678,ou=policy-stores,ou=admin-ui,o=jans", + "inum": "e1a2b3c4-1234-5678-9abc-def012345678", + "displayname": "Admin UI Cedarling Policy Store", + "description": "Cedarling policy store used to derive Admin UI role-to-scope mappings", + "policyStore": "UEsDBBQACAgIAABb...==", + "jansUsrDN": "inum=abc123,ou=people,o=jans", + "jansStatus": "active", + "creationDate": "2026-07-14T10:15:30.000Z", + "jansLastUpd": "2026-07-14T11:20:45.000Z" + } + ] + } "400": description: Bad Request content: @@ -757,18 +823,146 @@ paths: security: - oauth2: - https://jans.io/oauth/jans-auth-server/config/adminui/security.readonly + post: + tags: + - Admin UI - Cedarling + summary: Create Admin UI Policy Store + description: Create Admin UI Policy Store + operationId: create-adminui-policy-store + requestBody: + description: Policy Store Object + content: + application/json: + schema: + $ref: "#/components/schemas/AdminUIPolicyStore" + examples: + Request json example: + description: Request json example + value: | + { + "displayname": "Admin UI Cedarling Policy Store", + "description": "Cedarling policy store used to derive Admin UI role-to-scope mappings", + "policyStore": "UEsDBBQACAgIAABb...==", + "jansStatus": "active" + } + responses: + "200": + description: Ok + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/GenericResponse" + examples: + Response json example: + description: Response json example + value: | + { + "success": true, + "responseCode": 200, + "responseMessage": "Policy store saved successfully." + } + "400": + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "401": + description: Unauthorized + "500": + description: InternalServerError + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + security: + - oauth2: + - https://jans.io/oauth/jans-auth-server/config/adminui/security.write + /admin-ui/security/policyStore/{INUM}: put: tags: - Admin UI - Cedarling - summary: Upload Admin UI Policy Store - description: Upload Admin UI Policy Store - operationId: upload-adminui-policy-store + summary: Edit Admin UI Policy Store + description: Edit Admin UI Policy Store + operationId: edit-adminui-policy-store + parameters: + - name: INUM + in: path + description: Policy store inum + required: true + schema: + type: string requestBody: - description: String multipart form. + description: Policy Store Object content: - multipart/form-data: + application/json: schema: $ref: "#/components/schemas/AdminUIPolicyStore" + examples: + Request json example: + description: Request json example + value: | + { + "displayname": "Admin UI Cedarling Policy Store (updated)", + "description": "Updated description for the Admin UI policy store", + "jansStatus": "inactive" + } + responses: + "200": + description: Ok + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/GenericResponse" + examples: + Response json example: + description: Response json example + value: | + { + "success": true, + "responseCode": 200, + "responseMessage": "Policy store updated successfully." + } + "400": + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "401": + description: Unauthorized + "404": + description: Not Found + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "500": + description: InternalServerError + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + security: + - oauth2: + - https://jans.io/oauth/jans-auth-server/config/adminui/security.write + delete: + tags: + - Admin UI - Cedarling + summary: Delete Admin UI Policy Store + description: Delete Admin UI Policy Store + operationId: delete-adminui-policy-store + parameters: + - name: INUM + in: path + description: Policy store inum + required: true + schema: + type: string responses: "200": description: Ok @@ -778,6 +972,15 @@ paths: type: array items: $ref: "#/components/schemas/GenericResponse" + examples: + Response json example: + description: Response json example + value: | + { + "success": true, + "responseCode": 200, + "responseMessage": "Policy store deleted successfully." + } "400": description: Bad Request content: @@ -786,6 +989,12 @@ paths: $ref: "#/components/schemas/GenericResponse" "401": description: Unauthorized + "404": + description: Not Found + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" "500": description: InternalServerError content: @@ -814,6 +1023,15 @@ paths: type: array items: $ref: "#/components/schemas/GenericResponse" + examples: + Response json example: + description: Response json example + value: | + { + "success": true, + "responseCode": 200, + "responseMessage": "Sync of role-to-scope mappings from the policy store completed successfully." + } "400": description: Bad Request content: @@ -1863,46 +2081,60 @@ components: value: type: string AdminUIPolicyStore: - required: - - document - - policyStore - type: object - properties: - document: - $ref: "#/components/schemas/Document" - policyStore: - type: string - format: binary - Document: type: object properties: dn: type: string + description: Distinguished Name (DN) of the policy store entry. Server-generated + on create; read-only and ignored on edit. + readOnly: true + example: "inum=e1a2b3c4-1234-5678-9abc-def012345678,ou=policy-stores,ou=admin-ui,o=jans" inum: type: string - fileName: - type: string - filePath: + description: Unique identifier (inum) of the policy store. Server-generated + on create; read-only and ignored on edit. + readOnly: true + example: e1a2b3c4-1234-5678-9abc-def012345678 + displayname: type: string + description: Human-readable display name of the policy store. + example: Admin UI Cedarling Policy Store description: type: string - document: + description: Free-text description of the policy store and its purpose. + example: Cedarling policy store used to derive Admin UI role-to-scope mappings + policyStore: + type: string + description: Base64-encoded Cedar policy store archive (.cjar zip). Required + on create; immutable and ignored on edit. + example: UEsDBBQACAgIAABb...== + jansUsrDN: type: string + description: DN of the user who owns the policy store. Set at create time; + cannot be reassigned via edit. + example: "inum=abc123,ou=people,o=jans" + jansStatus: + type: string + description: Status of the policy store. Only one store should be 'active' + at a time; newly created stores default to 'inactive'. + example: active + enum: + - active + - inactive creationDate: type: string + description: Timestamp when the policy store was created. Server-managed + and fixed at create time. format: date-time - service: - type: string - level: - type: integer - format: int32 - revision: - type: integer - format: int32 - enabled: - type: boolean - baseDn: + readOnly: true + example: 2026-07-14T10:15:30Z + jansLastUpd: type: string + description: Timestamp of the last update. Server-managed and overwritten + on every create/edit; any client-supplied value is ignored. + format: date-time + readOnly: true + example: 2026-07-14T11:20:45Z LicenseRequest: type: object properties: