Ruby library (premium)
All notable changes to the habarico-enterprise gem are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[7.2.0] — 2026-10-19
Changed
- BREAKING: Requires the free library
habarico >= 7.2.0, installed from rubygems.org. The version now follows the free libraries' shared 7.2 line. (HAB-1034)
[1.0.0] — 2026-10-10
Added
- Initial
habarico-enterprisegem, built on the extension points added to the free gem in the same change. Adding it to the Gemfile registers every feature; nothing has to be wired up by hand. Alib/habarico-enterprise.rbshim makesBundler.requirefind the entry point despite the hyphen in the gem name — without it the gem would load silently as a no-op. (HAB-812) HabaricoEnterprise::Engine— aRails::Engineso Rails picks up this gem'sapp/directory. Without it the controllers behind the contributed routes are never autoloadable and every request to/searchor/{resource}/aggregate/schemafails withuninitialized constant. (HAB-812)- Global spotlight search, moved from the free gem:
GET /search,HabaricoEnterprise::SearchService,HabaricoEnterprise::Search::SearchHighlighter,SearchResourceSchemaand its generator, plus theglobal_fulltext_search_enabledopt-in it adds to the resource DSL and to the global resource schema. The list endpoint'smeta.highlightscomes from here too, throughHabarico::Config.search_highlighter_class. (HAB-812) - Change history, moved from the free gem's
habarico/contrib/paper_trail:habarico_enterprise/paper_trail—VersionController,VersionSerializer,DiffRenderer,VersionResourceSchemaGenerator, thePaperTrail::Versionmodel extensions and 13 locale files. Opt-in: needs thepaper_trailanddiff_match_patchgems. (HAB-812) can_read_historyon the resource DSL — declares who may read a tracked resource's change history, with the same bool-or-callable contract ascan_read. Left unset, the history follows the resource'scan_read. (HAB-903)- Chunked/direct uploads, moved from the free gem's
habarico/contrib/uploads:habarico_enterprise/uploads— the tus and S3 multipart providers,Uploads::Config,Uploads::Providers,HabaricoEnterprise::UploadOptions, theinitiate/complete/abortendpoints (HabaricoEnterprise::UploadsController) and the tusd helper scripts inscripts/. The three endpoints are now drawn byRoutingService.register_route_block, besideschemaat the admin-site root, instead of having to be mounted by hand as in the free contrib — a host upgrading should delete itspost "uploads/…"lines fromconfig/routes.rb. AccordinglyConfig::S3.initiate_urlbecame optional: left nil it resolves the drawn route. Registered throughEluvia::Uploads.finalizer,Habarico::FieldOptions(theuploadschema key) andHabarico::FieldOptionsGenerator(which fills it), so a free-only install emits nooptions.uploadat all. Opt-in —require "habarico_enterprise/uploads"— because it registers a process-wide upload finalizer and needs configuration; thes3provider additionally needs the host app's ownaws-sdk-s3. Matches the Python split, where chunked uploads are paid. (HAB-812, HAB-583) options.max_size/options.acceptonfile_attrfields - the client-side half of the upload rules, derived from the sameactive_storage_validationsdeclarationsinitiatereads and checked on a picked file before the upload starts, so a file that would be refused anyway never leaves the browser. Emitted for the base64 path too, since the validations describe the field rather than the transport;Config.max_upload_sizejoinsmax_sizeonly when thes3provider serves the field, sinceProviders::S3#initiateis the only place that enforces it. ARegexpcontent_type:yields noacceptat all rather than a partial list that would be stricter than the server, and the hard blocklist is deliberately not reflected inaccepteither - so an absent or permissiveacceptnever means the server will accept the file. Both keys are declared onHabarico::FieldOptionsby this gem, the same wayuploadis, and need the matching keys incontract/openapi.yaml. (HAB-942)- Aggregations and the dashboard, moved from the free gem:
habarico_enterprise/aggregations— the/{resource}/aggregateendpoint and its schema route,AggregateService,AggregateSchema/AggregateFieldSchemaand their generator,AggregateFieldDefinition, theaggregate_*/dashboard_enabledDSL options with their original defaults, andvalidate_aggregate_fields!. Registered throughDefinition,Habarico::Controller,RoutingService.register_resource_route_blockandGlobalResourceSchema. (HAB-812) can_read_aggregationson the resource DSL — declares who may reach a resource's aggregate endpoint and its schema, with the same bool-or-callable contract ascan_read. Left unset, the aggregations follow the resource'scan_read. (HAB-914)
Changed
- BREAKING: the gem moves to version 1, the major of the free
habaricogem it extends, and requireshabarico >= 1.0.0. The two gems share their major from here on, so the version of the free gem names the paid one that goes with it. (HAB-1019) max_size/accepton afile_attr's schema options are now derived by the freehabaricogem itself (Habarico::FileValidations) rather than only here - a free-only install gets both options too, not just the S3/tus one.Validations.for_model/.accept_forare now re-exports of the free gem's functions;Validations::Rules/::NONElikewise aliasHabarico::FileValidations::Rules/::NONE.apply_field_limitsno longer takesrulesand no longer touchesacceptat all - it only folds the project-wideConfig.max_upload_sizeceiling into whateveroptions.max_sizethe free gem already derived. No behaviour change for a project usinghabarico-enterprise-initiate, the signed target token, and the schema options it advertises are unaffected. (HAB-966)
Security
- The S3
initiateendpoint trusted the client-suppliedcontent_typeoutright and used it unchanged in the presigned URL's signature andcreate_multipart_upload;finalize'scopy_objectthen carried it forward into the final, served object with no re-check - so a client could make the browser later rendertext/html/image/svg+xmlinline (stored XSS).initiatenow rejectstext/html,application/xhtml+xmlandimage/svg+xmlby default, and additionally enforces thecontent_type:andsize:validations the host declared on thefile_attr'sunderlying_attachmentwithactive_storage_validations- a per-field content-type allowlist and size ceiling, the latter applied alongsideConfig.max_upload_size(the stricter of the two wins).active_storage_validationsstays outside the gemspec, likeaws-sdk-s3: a host that does not add it simply declares no rules and only the blocklist applies. A generatedinitiate_urlnow carries an opaque signed?field=token holding that field's rules, derived once when the schema is built - the signature is what makes carrying values safe, and the same values are advertised as the field'smax_size/acceptoptions in the same response, so the two cannot disagree.Providers::S3#initiatetakes a newrules:keyword - the field's limits the claimedcontent_type/sizeare measured against - andProviders::S3#field_schematakesrules:in place of themodel_label/field_nameit never used. (HAB-942) - The
text/html/application/xhtml+xml/image/svg+xmlblock above is a default, not an unconditional wall: a field whosecontent_type:validation names one of them exactly (e.g.content_type: 'image/svg+xml', or the:svgshorthand) now opts that field back in - a deliberate, field-scoped declaration, unlike a wildcard orRegexpmatch (image/*,%r{\Aimage/.*\z}), which still does not override the block, since a pattern is not a considered decision about SVG specifically. Serving an opted-in file safely (forced download, a sandboxed origin, CSP, sanitisation) stays the host's own responsibility. The default itself is now also a project-wide setting -Config.dangerous_content_types- rather than a hardcoded constant, so a host can replace or empty it entirely; emptying it removes the floor for every field with no restriction of its own, not just one naming a type exactly, so it carries the same serving-side responsibility at a larger scale. (HAB-966) - Unbounded
sizein the S3 multipartinitiate— the provider presigned one URL per part with no ceiling, so a claimedsizeof 10^16 asked it for ~1.9 billion presigned URLs and took the worker down with it.initiatenow refuses, before anything is created in S3, any upload needing more thanProviders::S3::MAX_PARTS(10 000, S3's own multipart limit) parts, plus any negativesize— all three as422 Unprocessable Entity, the status the Python library already answers such a request with. The newUploads::Config.max_upload_size(defaultnil) lets a host cap uploads further; left unset, the effective ceiling isMAX_PARTS * chunk_size— ~48.8 GiB at the default chunk size, which S3 could not have completed anyway. The refusals are translated, and the gem now has a catalog of its own:lib/habarico_enterprise/locales/*.ymlin 13 languages, loaded from the entry point, covering the upload messages plus the aggregation errors and the search yes/no labels that until now only had English fallbacks. (HAB-904) - The S3
finalizestep built the final upload key from the client-suppliedfilenamewith no sanitization, unlikeinitiatein the same provider, which already sanitizes it viaActiveStorage::Filename.finalizenow sanitizes the same way, and uses the sanitized name for both the S3 key and the attached blob'sfilenamemetadata. (HAB-911) - BREAKING: The aggregate schema endpoint ignored permissions entirely.
GET /{resource}/aggregatecheckedcan?(:read), butGET /{resource}/aggregate/schemachecked nothing —HabaricoEnterprise::Aggregations.resource_schemareceived the controller's context and discarded it (the parameter was named_context), handing anyone who could reach the route the aggregate field whitelist, the labels, the enum choices, the references and the resource's whole filter schema. Both routes now resolve the newcan_read_aggregations, falling back to the resource'scan_readwhen it is not declared: the schema endpoint answers404, which is what it already answers for a resource without the opt-in, and the data endpoint keeps answering403.aggregate_enabledin the global resource schema (GET /schema) is emitted asfalsefor a user who may not aggregate, so the frontend does not render a widget it cannot fill.Aggregations.resource_schemanow requires aHabarico::Contextand raisesEluvia::Errors::StandardErrorwithout one, the wayHabarico::SchemaServicealready does — a host calling it with a resource name alone must pass a context. A host relying on the aggregate endpoints being reachable withoutcan_readmust declarecan_read_aggregations trueon those resources. (HAB-914) - The
versionsresource no longer grants blanket read access. Reading a version now requires the permission of the tracked resource resolved from itsitem_type— the newcan_read_historywhen declared, the resource'scan_readotherwise — and every versions query is narrowed to readable item types, with the tracked resource's policy scope, scopes and default filter applied to the item ids. Versions of models without a registered resource are no longer readable, and fetching a version outside these permissions returns 404. (HAB-903) - Stored XSS in the change history diff — field values were inserted into
<ins>/<del>tags without HTML escaping, and the frontend renders the diff as rich text. A user with write access to a tracked field could store a payload that executes when an admin views the record's history. Values are now escaped the same waySearchHighlighteralready does. (HAB-897) - BREAKING: Unbounded
limitin the global spotlight search -GET /searchaccepted any number of digits and applied it per resource, so one request materialized up tolimitrecords of every resource withglobal_fulltext_search_enabledand ran the highlighter over each of them.limitis now validated by the newSearch::Limit.resolve: it must be an integer between 1 andSearch::Config.max_limit(default 100), and anything else - non-numeric, zero, negative, or over the ceiling - is refused with422, the status an over-wide aggregation already answers with. Only an absent (or empty)limitfalls back toSearch::Config.default_limit(default 5), which replaces the hardcoded value. Previously such a request silently returned 5 results, so a client sending a malformedlimitnow gets an error instead of data.GlobalSearchControllerresolves the param before callingSearchService#get_search_result, which now takes the resolved integer. (HAB-913) SearchService#get_search_resultbuilt its scope straight fromdefinition.model.alland ignored the current user entirely, so the global spotlight search returned records a user had nocan?(:read)permission for, records excluded bypolicy_scopeorscopes, and records hidden bydefault_filter(e.g. soft-deleted rows or other tenants' data). It now mirrorsindex: a resource without read permission is skipped entirely, andpolicy_scope,scopesanddefault_filterare applied before the fulltext match. (HAB-898)