Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8dd0b11
Do not block access for ALL users when the license user limit is exce…
KebabRonin Jul 28, 2025
47e64be
Do not block access for ALL users when the license user limit is exce…
KebabRonin Jul 29, 2025
e4ac1ef
Do not block access for ALL users when the license user limit is exce…
KebabRonin Aug 6, 2025
52f9e35
Do not block access for ALL users when the license user limit is exce…
KebabRonin Aug 25, 2025
7fc507a
Do not block access for ALL users when the license user limit is exce…
KebabRonin Aug 25, 2025
c0cbb9f
Do not block access for ALL users when the license user limit is exce…
KebabRonin Aug 29, 2025
2de24c6
Do not block access for ALL users when the license user limit is exce…
KebabRonin Sep 2, 2025
d3eb916
Do not block access for ALL users when the license user limit is exce…
KebabRonin Sep 2, 2025
2d5a325
Do not block access for ALL users when the license user limit is exce…
KebabRonin Sep 2, 2025
2ba4b4b
Do not block access for ALL users when the license user limit is exce…
KebabRonin Sep 4, 2025
8051097
Do not block access for ALL users when the license user limit is exce…
KebabRonin Sep 17, 2025
762ea39
Disable users over license user limit for Auth extensions #206
KebabRonin Sep 18, 2025
a11d4e5
Disable users over license user limit for Auth extensions #206
KebabRonin Sep 19, 2025
6b4cc16
Merge branch 'master' into issue#206
KebabRonin Oct 9, 2025
46e0b37
Disable users over license user limit for Auth extensions #206
KebabRonin Oct 9, 2025
4cb3e21
Disable users over license user limit for Auth extensions #206
KebabRonin Oct 9, 2025
60db9d9
Do not block access for ALL users when the license user limit is exc…
KebabRonin Nov 7, 2025
461281a
Merge branch 'master' into issue#206
KebabRonin Nov 7, 2025
457509c
Do not block access for ALL users when the license user limit is exc…
KebabRonin Nov 7, 2025
fe20bee
Do not block access for ALL users when the license user limit is exc…
KebabRonin Nov 7, 2025
8c42396
Do not block access for ALL users when the license user limit is exc…
KebabRonin Nov 7, 2025
6ef265a
Do not block access for ALL users when the license user limit is exce…
KebabRonin Dec 18, 2025
82100e2
Update application-licensing-licensor/application-licensing-licensor-…
abrassat Jan 29, 2026
7433457
Do not block access for ALL users when the license user limit is exce…
abrassat Jan 29, 2026
661e2ea
Do not block access for ALL users when the license user limit is exce…
KebabRonin Feb 3, 2026
c28e399
Do not block access for ALL users when the license user limit is exce…
abrassat Mar 9, 2026
dc64443
Do not block access for ALL users when the license user limit is exce…
abrassat Mar 17, 2026
6b84ba7
Do not block access for ALL users when the license user limit is exce…
abrassat Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ public interface LicenseManager
*/
License get(ExtensionId extensionId);

/**
* Retrieve the currently applicable license for the given installed extension.
* @param extensionId identifier of an installed extension (version is resolved automatically)
* @return a license.
*/
License get(String extensionId);

/**
* Add a new license to the current set of active license. The added license is checked to be applicable to the
* current wiki instance, else it will not be added. The license is also checked to be more interesting than the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ public interface Licensor
*/
License getLicense();

/**
* Retrieve the currently applicable license for the given installed extension.
*
* @param extensionId name of an installed extension. This method automatically resolves the version of the
* extension which is installed
* @return a license, or null if the given installed extension is not subject to licensing.
*/
License getLicense(String extensionId);

/**
* Retrieve the currently applicable license for the given installed extension.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
* This is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* This software is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this software; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package com.xwiki.licensing.internal;

import java.util.List;

import org.xwiki.component.annotation.Role;
import org.xwiki.model.reference.DocumentReference;

import com.xpn.xwiki.XWikiContext;
import com.xpn.xwiki.doc.XWikiDocument;

/**
* Interface for extensions which manage users, so the license user limit can only take into account the users managed
* by those extensions for computing the user limit.
* <p>
* Components implementing this interface should be named as extension id, so the user manager is discoverable.
*
* @version $Id$
* @since 1.31
*/
@Role
public interface AuthExtensionUserManager
{
/**
* True if this manager manages the given user. Should return false for invalid licenses.
*
* @param user the user to check
* @return true if this manager manages the given user.
*/
boolean managesUser(DocumentReference user);

/**
* True if this manager manages the given user. Should return false for invalid licenses.
*
* @param user the user to check
* @return true if this manager manages the given user.
*/
boolean managesUser(XWikiDocument user);

/**
* Get a list of all users managed by this manager.
*
* @return a list of all users managed by this manager
*/
List<XWikiDocument> getManagedUsers();

/**
* Get a list of all active users managed by this manager.
*
* @return a list of all active users managed by this auth extension
*/
List<XWikiDocument> getActiveManagedUsers();

/**
* Resolve a username (the string used by a user to log in) to the XWiki user page. This method should resolve all
* valid usernames that a user can use to log into their account.
*
* @param username the username used by a user to login
* @param context XWiki context, to help in querying pages
* @return a reference to the user page with the XWiki.XWikiUsers object, or null if not existent
*/
DocumentReference getUserDocFromUsername(String username, XWikiContext context);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Provider;
import javax.inject.Singleton;

import org.slf4j.Logger;
Expand Down Expand Up @@ -115,6 +116,9 @@ public void run()
@Inject
private LicensedExtensionManager licensedExtensionManager;

@Inject
private Provider<InstalledExtensionRepository> installedExtensionRepositoryProvider;

private final Map<LicenseId, License> licenses = new HashMap<>();

private final Map<LicenseId, Integer> licensesUsage = new HashMap<>();
Expand Down Expand Up @@ -327,6 +331,14 @@ public License get(ExtensionId extensionId)
return extensionToLicense.get(extId);
}

@Override
public License get(String extensionId)
{
InstalledExtension installedExtension =
this.installedExtensionRepositoryProvider.get().getInstalledExtension(extensionId, null);
return this.get(installedExtension.getId());
}

@Override
public boolean add(License license)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,27 @@

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.stream.Collectors;

import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;

import org.apache.commons.lang3.builder.CompareToBuilder;
import org.slf4j.Logger;
import org.xwiki.bridge.event.DocumentCreatedEvent;
import org.xwiki.bridge.event.DocumentDeletedEvent;
import org.xwiki.bridge.event.DocumentUpdatedEvent;
import org.xwiki.component.annotation.Component;
import org.xwiki.model.reference.DocumentReference;
import org.xwiki.model.reference.LocalDocumentReference;
import org.xwiki.observation.AbstractEventListener;
import org.xwiki.observation.event.Event;
import org.xwiki.observation.event.filter.EventFilter;
import org.xwiki.observation.event.filter.RegexEventFilter;
import org.xwiki.query.Query;
import org.xwiki.query.QueryException;
import org.xwiki.query.QueryFilter;
Expand All @@ -54,6 +62,11 @@
@Singleton
public class UserCounter
{
protected static final String BASE_USER_QUERY = ", BaseObject as obj, IntegerProperty as prop "
+ "where doc.space = 'XWiki' "
+ "and doc.fullName = obj.name and obj.className = 'XWiki.XWikiUsers' and prop.id.id = obj.id "
+ "and prop.id.name = 'active' and prop.value = '1'";

@Inject
private Logger logger;

Expand All @@ -73,6 +86,12 @@ public class UserCounter

private Long cachedUserCount;

// A set of users on the instance, sorted by creation date.
private SortedSet<XWikiDocument> cachedSortedUsers;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you see how we could do some performance tests? We would need to know that this doesn't fail on an instance with a loot of users

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing this depends on the JVM allocated memory. Iirc, this comment mentioned that the cache size should be ok (at least proportionally to the instance size).

I'll try to create a test with ~10.000 users on the docker instance, but I'll have to see if the test instance can really support 10k users.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice if we could have it. I know there were some script to create multiple users / wikis
This could be done as an extra step after you finish the code part :)


// Helper to find users in constant time.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the cachedSortedUsers is populated from the database, dummy XWikiDocuments can't be used to index into the set directly (equals fails because it checks for a bunch of fields like version, author, etc.).

This is the reason why the map is needed.

private Map<DocumentReference, XWikiDocument> cachedSortedUsersLookupTable;

/**
* Event listener that invalidates the cached user count when an user is added, deleted or the active property's
* value is changed.
Expand All @@ -94,6 +113,8 @@ public static class UserListener extends AbstractEventListener

protected static final LocalDocumentReference USER_CLASS = new LocalDocumentReference("XWiki", "XWikiUsers");

private static final EventFilter XWIKI_SPACE_FILTER = new RegexEventFilter("(.*:)?XWiki\\..*");

@Inject
private UserCounter userCounter;

Expand All @@ -102,8 +123,8 @@ public static class UserListener extends AbstractEventListener
*/
public UserListener()
{
super(HINT,
Arrays.asList(new DocumentCreatedEvent(), new DocumentUpdatedEvent(), new DocumentDeletedEvent()));
super(HINT, Arrays.asList(new DocumentCreatedEvent(XWIKI_SPACE_FILTER),
new DocumentUpdatedEvent(XWIKI_SPACE_FILTER), new DocumentDeletedEvent(XWIKI_SPACE_FILTER)));
}

@Override
Expand All @@ -124,9 +145,38 @@ public void onEvent(Event event, Object source, Object data)

if (newDocumentIsUser != oldDocumentIsUser || newActive != oldActive) {
// The user object is either added/removed or set to active/inactive. Invalidate the cached user count.
this.userCounter.cachedUserCount = null;
this.userCounter.flushCache();
}
}
}

/**
* Flush the cache of the user counter.
*/
public void flushCache()
{
this.cachedUserCount = null;
this.cachedSortedUsers = null;
this.cachedSortedUsersLookupTable = null;
}

/**
* Get the users sorted by creation date.
*
* @return the users, sorted by creation date.
*/
public SortedSet<XWikiDocument> getSortedUsers() throws WikiManagerException, QueryException
{
if (cachedSortedUsers == null) {
cachedSortedUsers = new TreeSet<>(
(e1, e2) -> new CompareToBuilder().append(e1.getCreationDate(), e2.getCreationDate()).build());
for (String wikiId : wikiDescriptorManager.getAllIds()) {
cachedSortedUsers.addAll(getUsersOnWiki(wikiId));
}
cachedSortedUsersLookupTable =
cachedSortedUsers.stream().collect(Collectors.toMap(XWikiDocument::getDocumentReference, e -> e));
}
return cachedSortedUsers;
}

/**
Expand Down Expand Up @@ -155,13 +205,40 @@ public long getUserCount() throws Exception

private long getUserCountOnWiki(String wikiId) throws QueryException
{
StringBuilder statement = new StringBuilder(", BaseObject as obj, IntegerProperty as prop ");
statement.append("where doc.fullName = obj.name and obj.className = 'XWiki.XWikiUsers' and ");
statement.append("prop.id.id = obj.id and prop.id.name = 'active' and prop.value = '1'");

Query query = this.queryManager.createQuery(statement.toString(), Query.HQL);
Query query = this.queryManager.createQuery(BASE_USER_QUERY, Query.HQL);
query.addFilter(this.uniqueFilter).addFilter(this.countFilter).setWiki(wikiId);
List<Long> results = query.execute();
return results.get(0);
}

/**
* Return whether the given user is under the specified license user limit.
*
* @param user the user to check
* @param userLimit the license max user limit
* @return whether the given user is under the specified license user limit
*/
public boolean isUserUnderLimit(DocumentReference user, long userLimit) throws Exception
{
if (userLimit < 0 || userLimit <= getUserCount()) {
// Unlimited licenses should always return true.
// Also, skip the checks for instances with fewer users than the limit.
return true;
}
SortedSet<XWikiDocument> sortedUsers = getSortedUsers();
// Lookup table is initialized in getSortedUsers().
XWikiDocument userDocument = cachedSortedUsersLookupTable.get(user);
if (userDocument == null) {
return false;
} else {
return sortedUsers.headSet(userDocument).size() < userLimit;
}
}


private List<XWikiDocument> getUsersOnWiki(String wikiId) throws QueryException
{
return this.queryManager.createQuery("select doc from XWikiDocument doc" + BASE_USER_QUERY, Query.HQL)
.setWiki(wikiId).execute();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ public License getLicense(EntityReference reference)
return null;
}

@Override
public License getLicense(String extensionId)
{
try {
return licenseManager.get(extensionId);
} catch (Throwable e) {
// Ignored
}
return null;
}

@Override
public License getLicense(ExtensionId extensionId)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,20 +61,13 @@ public class UserCounterTest

private QueryFilter uniqueFilter;

private String statement;

@Before
public void configure() throws Exception
{
this.wikiDescriptorManager = this.mocker.getInstance(WikiDescriptorManager.class);
this.queryManager = this.mocker.getInstance(QueryManager.class);
this.countFilter = this.mocker.getInstance(QueryFilter.class, "count");
this.uniqueFilter = this.mocker.getInstance(QueryFilter.class, "unique");

StringBuilder stringBuilder = new StringBuilder(", BaseObject as obj, IntegerProperty as prop ");
stringBuilder.append("where doc.fullName = obj.name and obj.className = 'XWiki.XWikiUsers' and ");
stringBuilder.append("prop.id.id = obj.id and prop.id.name = 'active' and prop.value = '1'");
this.statement = stringBuilder.toString();
}

@Test
Expand All @@ -93,7 +86,7 @@ public void getUserCount() throws Exception

Query fooQuery = createMockQuery("foo");
Query barQuery = createMockQuery("bar");
when(this.queryManager.createQuery(this.statement, Query.HQL)).thenReturn(fooQuery, barQuery);
when(this.queryManager.createQuery(UserCounter.BASE_USER_QUERY, Query.HQL)).thenReturn(fooQuery, barQuery);
when(fooQuery.execute()).thenReturn(Collections.singletonList(3L));
when(barQuery.execute()).thenReturn(Collections.singletonList(4L));

Expand All @@ -115,7 +108,7 @@ public void getUserCountThrowsQueryException() throws Exception

Query fooQuery = createMockQuery("foo");
Query barQuery = createMockQuery("bar");
when(this.queryManager.createQuery(this.statement, Query.HQL)).thenReturn(fooQuery, barQuery);
when(this.queryManager.createQuery(UserCounter.BASE_USER_QUERY, Query.HQL)).thenReturn(fooQuery, barQuery);
when(fooQuery.execute()).thenReturn(Collections.singletonList(3L));
when(barQuery.execute()).thenThrow(new QueryException("message", barQuery, null));

Expand All @@ -133,7 +126,7 @@ public void getUserCountWithCacheInvalidation() throws Exception
when(this.wikiDescriptorManager.getAllIds()).thenReturn(Collections.singletonList("foo"));

Query fooQuery = createMockQuery("foo");
when(this.queryManager.createQuery(this.statement, Query.HQL)).thenReturn(fooQuery);
when(this.queryManager.createQuery(UserCounter.BASE_USER_QUERY, Query.HQL)).thenReturn(fooQuery);
when(fooQuery.execute()).thenReturn(Collections.singletonList(3L));

assertEquals(3L, this.mocker.getComponentUnderTest().getUserCount());
Expand Down