/* * Copyright @ 2013 CurisTEC, S.A.S. All Rights Reserved. */ package net.curisit.securis.services; import java.security.Principal; import java.util.ArrayList; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Set; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.persistence.EntityManager; import jakarta.persistence.TypedQuery; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; import jakarta.ws.rs.core.UriInfo; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import net.curisit.integrity.commons.Utils; import net.curisit.securis.DefaultExceptionHandler; import net.curisit.securis.SeCurisException; import net.curisit.securis.db.License; import net.curisit.securis.db.LicenseStatus; import net.curisit.securis.db.LicenseType; import net.curisit.securis.db.Organization; import net.curisit.securis.db.Pack; import net.curisit.securis.db.PackMetadata; import net.curisit.securis.db.PackStatus; import net.curisit.securis.db.User; import net.curisit.securis.db.User.Rol; import net.curisit.securis.ioc.EnsureTransaction; import net.curisit.securis.security.BasicSecurityContext; import net.curisit.securis.security.Securable; import net.curisit.securis.services.exception.SeCurisServiceException; import net.curisit.securis.services.exception.SeCurisServiceException.ErrorCodes; import net.curisit.securis.services.helpers.LicenseHelper; import net.curisit.securis.services.helpers.MetadataHelper; import net.curisit.securis.utils.LicUtils; import net.curisit.securis.utils.TokenHelper; /** * PackResource *

* Manages Packs (group of licenses bound to an organization, application/type, * and configuration/metadata). Provides list/filter, get, create, modify, * state transitions (activate/hold/cancel) and deletion. * * @author JRA * Last reviewed by JRA on Oct 5, 2025. */ @Path("/pack") public class PackResource { private static final Logger LOG = LogManager.getLogger(PackResource.class); @Inject TokenHelper tokenHelper; @Inject MetadataHelper metadataHelper; @Inject private LicenseHelper licenseHelper; @Context EntityManager em; /** * index *

* List packs with optional filters (organizationId, applicationId, licenseTypeId). * For non-admins, results are scoped by both apps and orgs from {@link BasicSecurityContext}. * * @param uriInfo supplies query parameters. * @param bsc security scope/roles. * @return 200 OK with the list (possibly empty). */ @GET @Path("/") @Securable @Produces({ MediaType.APPLICATION_JSON }) public Response index(@Context UriInfo uriInfo, @Context BasicSecurityContext bsc) { LOG.info("Getting packs list "); MultivaluedMap queryParams = uriInfo.getQueryParameters(); em.clear(); TypedQuery q = createQuery(queryParams, bsc); if (q == null) { return Response.ok().build(); } List list = q.getResultList(); return Response.ok(list).build(); } /** * get *

* Fetch a pack by id. If the caller is an ADVANCE user, validates * the organization scope. * * @param packId pack id. * @param bsc security context. * @return 200 OK with entity, or 404/401 accordingly. */ @GET @Path("/{packId}") @Securable @Produces({ MediaType.APPLICATION_JSON }) public Response get(@PathParam("packId") Integer packId, @Context BasicSecurityContext bsc) { LOG.info("Getting pack data for id: {}: ", packId); if (packId == null || "".equals(Integer.toString(packId))) { LOG.error("Pack ID is mandatory"); return Response.status(Status.NOT_FOUND).build(); } em.clear(); Pack pack = em.find(Pack.class, packId); if (pack == null) { LOG.error("Pack with id {} not found in DB", packId); return Response.status(Status.NOT_FOUND).build(); } if (bsc.isUserInRole(BasicSecurityContext.ROL_ADVANCE) && (bsc.getOrganizationsIds() == null || !bsc.getOrganizationsIds().contains(pack.getOrgId()))) { return generateErrorUnathorizedAccess(pack, bsc.getUserPrincipal()); } return Response.ok(pack).build(); } /** * create *

* Create a new pack. Validates code uniqueness, sets organization and * license type references, stamps creator and timestamps, and persists * metadata entries. * * @param pack payload. * @param bsc security context (for createdBy). * @return 200 OK with created pack or 404 when references not found. * @throws SeCurisServiceException on duplicated code. */ @POST @Path("/") @Securable(roles = Rol.ADMIN | Rol.ADVANCE) @RolesAllowed(BasicSecurityContext.ROL_ADMIN) @Consumes(MediaType.APPLICATION_JSON) @Produces({ MediaType.APPLICATION_JSON }) @EnsureTransaction public Response create(Pack pack, @Context BasicSecurityContext bsc) throws SeCurisServiceException { LOG.info("Creating new pack"); if (checkIfCodeExists(pack.getCode(), em)) { throw new SeCurisServiceException(ErrorCodes.INVALID_DATA, "The pack code is already used in an existing pack"); } try { setPackOrganization(pack, pack.getOrgId(), em); } catch (SeCurisException e) { return Response.status(Status.NOT_FOUND).header(DefaultExceptionHandler.ERROR_MESSAGE_HEADER, e.getMessage()).build(); } try { setPackLicenseType(pack, pack.getLicTypeId(), em); } catch (SeCurisException e) { return Response.status(Status.NOT_FOUND).header(DefaultExceptionHandler.ERROR_MESSAGE_HEADER, e.getMessage()).build(); } User user = em.find(User.class, bsc.getUserPrincipal().getName()); pack.setStatus(PackStatus.CREATED); pack.setCreatedBy(user); pack.setCreationTimestamp(new Date()); em.persist(pack); Set newMD = pack.getMetadata(); if (newMD != null) { for (PackMetadata md : newMD) { md.setPack(pack); em.persist(md); } } pack.setMetadata(newMD); return Response.ok(pack).build(); } /** * modify *

* Update a pack basic fields and reconcile metadata (remove/merge/persist). * If metadata keys changed, marks dependent licenses metadata as obsolete via * {@link MetadataHelper#markObsoleteMetadata}. * * @param pack payload values. * @param packId target id. * @return 200 OK with updated pack or 404 on ref errors. */ @PUT @POST @Path("/{packId}") @EnsureTransaction @Securable(roles = Rol.ADMIN | Rol.ADVANCE) @RolesAllowed(BasicSecurityContext.ROL_ADMIN) @Consumes(MediaType.APPLICATION_JSON) @Produces({ MediaType.APPLICATION_JSON }) public Response modify(Pack pack, @PathParam("packId") Integer packId) { LOG.info("Modifying pack with id: {}", packId); Pack currentPack = em.find(Pack.class, packId); try { setPackOrganization(currentPack, pack.getOrgId(), em); } catch (SeCurisException e) { return Response.status(Status.NOT_FOUND).header(DefaultExceptionHandler.ERROR_MESSAGE_HEADER, e.getMessage()).build(); } try { setPackLicenseType(currentPack, pack.getLicTypeId(), em); } catch (SeCurisException e) { return Response.status(Status.NOT_FOUND).header(DefaultExceptionHandler.ERROR_MESSAGE_HEADER, e.getMessage()).build(); } currentPack.setLicensePreactivation(pack.isLicensePreactivation()); currentPack.setCode(pack.getCode()); currentPack.setComments(pack.getComments()); currentPack.setNumLicenses(pack.getNumLicenses()); currentPack.setPreactivationValidPeriod(pack.getPreactivationValidPeriod()); currentPack.setRenewValidPeriod(pack.getRenewValidPeriod()); currentPack.setInitValidDate(pack.getInitValidDate()); currentPack.setEndValidDate(pack.getEndValidDate()); Set newMD = pack.getMetadata(); boolean metadataChanges = !metadataHelper.match(newMD, currentPack.getMetadata()); if (metadataChanges) { Set newMdKeys = getMdKeys(newMD); for (PackMetadata currentMd : currentPack.getMetadata()) { if (!newMdKeys.contains(currentMd.getKey())) { em.remove(currentMd); } } if (newMD != null) { Set oldMdKeys = getMdKeys(newMD); for (PackMetadata md : newMD) { if (oldMdKeys.contains(md.getKey())) { em.merge(md); } else { md.setPack(currentPack); em.persist(md); } } } currentPack.setMetadata(newMD); } em.merge(currentPack); if (metadataChanges) { metadataHelper.markObsoleteMetadata(em, currentPack); } return Response.ok(currentPack).build(); } /** * activate *

* Move a pack to ACTIVE (only from allowed states). * * @param packId target pack id. * @return 200 OK with updated pack or error when invalid transition. * @throws SeCurisServiceException when invalid state transition. */ @POST @Path("/{packId}/activate") @EnsureTransaction @Securable(roles = Rol.ADMIN | Rol.ADVANCE) @RolesAllowed(BasicSecurityContext.ROL_ADMIN) @Consumes(MediaType.APPLICATION_JSON) @Produces({ MediaType.APPLICATION_JSON }) public Response activate(@PathParam("packId") Integer packId) throws SeCurisServiceException { LOG.info("Activating pack with id: {}", packId); Pack currentPack = em.find(Pack.class, packId); if (!Pack.Status.isActionValid(Pack.Action.ACTIVATION, currentPack.getStatus())) { LOG.error("Pack with id {} cannot be activaed from status {}", packId, currentPack.getStatus().name()); throw new SeCurisServiceException(ErrorCodes.WRONG_STATUS, "Pack cannot be activated in status: " + currentPack.getStatus().name()); } currentPack.setStatus(PackStatus.ACTIVE); em.persist(currentPack); return Response.ok(currentPack).build(); } /** * onhold *

* Put a pack ON_HOLD from allowed states. * * @param packId id. * @return 200 OK with updated pack or error on invalid state. * @throws SeCurisServiceException on invalid state. */ @POST @Path("/{packId}/putonhold}") @EnsureTransaction @Securable(roles = Rol.ADMIN | Rol.ADVANCE) @RolesAllowed(BasicSecurityContext.ROL_ADMIN) @Consumes(MediaType.APPLICATION_JSON) @Produces({ MediaType.APPLICATION_JSON }) public Response onhold(@PathParam("packId") Integer packId) throws SeCurisServiceException { LOG.info("Putting On hold pack with id: {}", packId); Pack currentPack = em.find(Pack.class, packId); if (!Pack.Status.isActionValid(Pack.Action.PUT_ONHOLD, currentPack.getStatus())) { LOG.error("Pack with id {} cannot be put on hold from status {}", packId, currentPack.getStatus().name()); throw new SeCurisServiceException(ErrorCodes.WRONG_STATUS, "Pack cannot be put on hold in status: " + currentPack.getStatus().name()); } currentPack.setStatus(PackStatus.ON_HOLD); em.persist(currentPack); return Response.ok(currentPack).build(); } /** * cancel *

* Cancel a pack. Cascades cancel to ACTIVE/PRE_ACTIVE licenses in the pack * via {@link LicenseHelper#cancelLicense}. * * @param packId id. * @param reason cancellation reason. * @param bsc actor for history entries. * @return 200 OK with updated pack. * @throws SeCurisServiceException on invalid state. */ @POST @Path("/{packId}/cancel") @EnsureTransaction @Securable(roles = Rol.ADMIN | Rol.ADVANCE) @RolesAllowed(BasicSecurityContext.ROL_ADMIN) @Consumes(MediaType.APPLICATION_JSON) @Produces({ MediaType.APPLICATION_JSON }) public Response cancel(@PathParam("packId") Integer packId, @FormParam("reason") String reason, @Context BasicSecurityContext bsc) throws SeCurisServiceException { LOG.info("Cancelling pack with id: {}", packId); Pack currentPack = em.find(Pack.class, packId); if (!Pack.Status.isActionValid(Pack.Action.CANCEL, currentPack.getStatus())) { LOG.error("Pack with id {} cannot cancelled from status {}", packId, currentPack.getStatus().name()); throw new SeCurisServiceException(ErrorCodes.WRONG_STATUS, "Pack cannot be cancelled in status: " + currentPack.getStatus().name()); } Set licenses = currentPack.getLicenses(); for (License license : licenses) { if (license.getStatus() == LicenseStatus.ACTIVE || license.getStatus() == LicenseStatus.PRE_ACTIVE) { licenseHelper.cancelLicense(license, "Pack cancellation. " + reason, bsc, em); } } currentPack.setStatus(PackStatus.CANCELLED); em.persist(currentPack); return Response.ok(currentPack).build(); } /** * getCodeSuffix *

* Compute the next available license code for a pack, by asking the helper * for the next numeric suffix and composing with {@link LicUtils}. * * @param packId id. * @param bsc (unused) security context. * @return 200 OK with the full code text. * @throws SeCurisServiceException if packId missing. */ @GET @Path("/{packId}/next_license_code") @Securable(roles = Rol.ADMIN | Rol.ADVANCE) @Produces({ MediaType.TEXT_PLAIN }) public Response getCodeSuffix(@PathParam("packId") Integer packId, @Context BasicSecurityContext bsc) throws SeCurisServiceException { if (packId == null) { throw new SeCurisServiceException(ErrorCodes.INVALID_DATA, "The pack code is mandatory"); } Integer codeSuffix = licenseHelper.getNextCodeSuffix(packId, em); Pack pack = em.find(Pack.class, packId); String licCode = LicUtils.getLicenseCode(pack.getCode(), codeSuffix); return Response.ok(licCode).build(); } /** * delete *

* Delete a pack after ensuring there are no ACTIVE/PRE_ACTIVE licenses. * Removes remaining licenses then the pack itself. * * @param packId String id. * @return 200 OK with success map, 404 if missing, or 409 if active license exists. * @throws SeCurisServiceException on constraint errors. */ @DELETE @Path("/{packId}") @Securable(roles = Rol.ADMIN | Rol.ADVANCE) @RolesAllowed(BasicSecurityContext.ROL_ADMIN) @EnsureTransaction @Produces({ MediaType.APPLICATION_JSON }) public Response delete(@PathParam("packId") String packId) throws SeCurisServiceException { LOG.info("Deleting pack with id: {}", packId); Pack pack = em.find(Pack.class, Integer.parseInt(packId)); if (pack == null) { LOG.error("Pack with id {} can not be deleted, It was not found in DB", packId); return Response.status(Status.NOT_FOUND).header(DefaultExceptionHandler.ERROR_MESSAGE_HEADER, "Pack was not found, ID: " + packId).build(); } Set licenses = pack.getLicenses(); for (License license : licenses) { if (license.getStatus() == LicenseStatus.ACTIVE || license.getStatus() == LicenseStatus.PRE_ACTIVE) { throw new SeCurisServiceException(ErrorCodes.WRONG_STATUS, "An active license cannot be deleted. License code: " + license.getCode()); } em.remove(license); } em.remove(pack); return Response.ok(Utils.createMap("success", true, "id", packId)).build(); } // --------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------- /** * generateWhereFromParams

* Generate where clause to include to a query * * @param addWhere * @param queryParams * @return whereClause */ private String generateWhereFromParams(boolean addWhere, MultivaluedMap queryParams) { List conditions = new ArrayList<>(); if (queryParams.containsKey("organizationId")) { conditions.add(String.format("pa.organization.id = %s", queryParams.getFirst("organizationId"))); } if (queryParams.containsKey("applicationId")) { conditions.add(String.format("pa.licenseType.application.id = %s", queryParams.getFirst("applicationId"))); } if (queryParams.containsKey("licenseTypeId")) { conditions.add(String.format("pa.licenseType.id = %s", queryParams.getFirst("licenseTypeId"))); } String connector = addWhere ? " where " : " and "; return (conditions.isEmpty() ? "" : connector) + String.join(" and ", conditions); } /** * createQuery

* Build a typed query considering role-based scopes and filters. * * @param queryParams * @param basicSecurityContext */ private TypedQuery createQuery(MultivaluedMap queryParams, BasicSecurityContext bsc) { TypedQuery q; String hql = "SELECT pa FROM Pack pa"; if (bsc.isUserInRole(BasicSecurityContext.ROL_ADMIN)) { hql += generateWhereFromParams(true, queryParams); q = em.createQuery(hql, Pack.class); } else { if (bsc.getApplicationsIds() == null || bsc.getApplicationsIds().isEmpty()) { return null; } if (bsc.getOrganizationsIds() == null || bsc.getOrganizationsIds().isEmpty()) { hql += " where pa.licenseType.application.id in :list_ids_app "; } else { hql += " where pa.organization.id in :list_ids_org and pa.licenseType.application.id in :list_ids_app "; } hql += generateWhereFromParams(false, queryParams); q = em.createQuery(hql, Pack.class); if (hql.contains("list_ids_org")) { q.setParameter("list_ids_org", bsc.getOrganizationsIds()); } q.setParameter("list_ids_app", bsc.getApplicationsIds()); LOG.info("Getting packs from orgs: {} and apps: {}", bsc.getOrganizationsIds(), bsc.getApplicationsIds()); } return q; } /** * generateErrorUnathorizedAccess

* Convenience 401 generator with log. * * @param pack * @param user */ private Response generateErrorUnathorizedAccess(Pack pack, Principal user) { LOG.error("Pack with id {} not accesible by user {}", pack, user); return Response.status(Status.UNAUTHORIZED).header(DefaultExceptionHandler.ERROR_MESSAGE_HEADER, "Unathorized access to pack").build(); } /** * setPackLicenseType

* Set the pack type * * @param pack * @param licTypeId * @param em * @throws SeCurisException */ private void setPackLicenseType(Pack pack, Integer licTypeId, EntityManager em) throws SeCurisException { LicenseType lt = null; if (licTypeId != null) { lt = em.find(LicenseType.class, pack.getLicTypeId()); if (lt == null) { LOG.error("Pack license type with id {} not found in DB", licTypeId); throw new SeCurisException("Pack license type not found with ID: " + licTypeId); } } pack.setLicenseType(lt); } /** * getMdKeys

* Get the MD keys * * @param mds * @return mdKeys */ private Set getMdKeys(Set mds) { Set ids = new HashSet(); if (mds != null) { for (PackMetadata md : mds) { ids.add(md.getKey()); } } return ids; } /** * checkIfCodeExists

* Check if the code already exist * * @param code * @param em * @return codeExist */ private boolean checkIfCodeExists(String code, EntityManager em) { TypedQuery query = em.createNamedQuery("pack-by-code", Pack.class); query.setParameter("code", code); int packs = query.getResultList().size(); return packs > 0; } /** * setPackOrganization

* Set the organization of the pack * * @param currentPack * @param orgId * @param em * @throws SeCurisException */ private void setPackOrganization(Pack currentPack, Integer orgId, EntityManager em) throws SeCurisException { Organization org = null; if (orgId != null) { org = em.find(Organization.class, orgId); if (org == null) { LOG.error("Organization pack with id {} not found in DB", orgId); throw new SeCurisException("Pack organization not found with ID: " + orgId); } } currentPack.setOrganization(org); } }