feat(license): enforce max_users at user creation paths

Wires LicenseEnforcer into UserAdminController.createUser and
OidcAuthController auto-signup. Cap fires before any validation so
over-cap creates short-circuit cheaply. Audit emission already
present (LicenseEnforcer 3-arg ctor from T16 emits cap_exceeded
under AuditCategory.LICENSE).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 14:29:54 +02:00
parent afdaee628b
commit 1ff30905f7
5 changed files with 148 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.SetPasswordRequest;
import com.cameleer.server.app.license.LicenseEnforcer;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
@@ -52,13 +53,16 @@ public class UserAdminController {
private final RbacService rbacService;
private final UserRepository userRepository;
private final AuditService auditService;
private final LicenseEnforcer licenseEnforcer;
private final boolean oidcEnabled;
public UserAdminController(RbacService rbacService, UserRepository userRepository,
AuditService auditService, SecurityProperties securityProperties) {
AuditService auditService, SecurityProperties securityProperties,
LicenseEnforcer licenseEnforcer) {
this.rbacService = rbacService;
this.userRepository = userRepository;
this.auditService = auditService;
this.licenseEnforcer = licenseEnforcer;
String issuer = securityProperties.getOidc().getIssuerUri();
this.oidcEnabled = issuer != null && !issuer.isBlank();
}
@@ -89,6 +93,9 @@ public class UserAdminController {
@ApiResponse(responseCode = "400", description = "Disabled in OIDC mode")
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request,
HttpServletRequest httpRequest) {
// License cap fires first so over-cap creates short-circuit before any other validation.
// Audit emission for the rejection is handled inside LicenseEnforcer (3-arg ctor wires AuditService).
licenseEnforcer.assertWithinCap("max_users", userRepository.count(), 1);
if (oidcEnabled) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO."));

View File

@@ -3,6 +3,7 @@ package com.cameleer.server.app.security;
import com.cameleer.server.app.dto.AuthTokenResponse;
import com.cameleer.server.app.dto.ErrorResponse;
import com.cameleer.server.app.dto.OidcPublicConfigResponse;
import com.cameleer.server.app.license.LicenseEnforcer;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
@@ -63,6 +64,7 @@ public class OidcAuthController {
private final ClaimMappingService claimMappingService;
private final ClaimMappingRepository claimMappingRepository;
private final GroupRepository groupRepository;
private final LicenseEnforcer licenseEnforcer;
public OidcAuthController(OidcTokenExchanger tokenExchanger,
OidcConfigRepository configRepository,
@@ -72,7 +74,8 @@ public class OidcAuthController {
RbacService rbacService,
ClaimMappingService claimMappingService,
ClaimMappingRepository claimMappingRepository,
GroupRepository groupRepository) {
GroupRepository groupRepository,
LicenseEnforcer licenseEnforcer) {
this.tokenExchanger = tokenExchanger;
this.configRepository = configRepository;
this.jwtService = jwtService;
@@ -82,6 +85,7 @@ public class OidcAuthController {
this.claimMappingService = claimMappingService;
this.claimMappingRepository = claimMappingRepository;
this.groupRepository = groupRepository;
this.licenseEnforcer = licenseEnforcer;
}
/**
@@ -154,6 +158,13 @@ public class OidcAuthController {
"Account not provisioned. Contact your administrator.");
}
// Auto-signup branch: when the user does not yet exist and the IdP is allowed to
// provision new accounts, enforce the max_users license cap before persisting.
// The global LicenseExceptionAdvice maps this to a structured 403 envelope.
if (existingUser.isEmpty() && config.get().autoSignup()) {
licenseEnforcer.assertWithinCap("max_users", userRepository.count(), 1);
}
userRepository.upsert(new UserInfo(
userId, provider, oidcUser.email(), oidcUser.name(), Instant.now()));

View File

@@ -101,6 +101,12 @@ public class PostgresUserRepository implements UserRepository {
java.sql.Timestamp.from(timestamp), userId);
}
@Override
public long count() {
Long n = jdbc.queryForObject("SELECT COUNT(*) FROM users", Long.class);
return n == null ? 0L : n;
}
private UserInfo mapUser(java.sql.ResultSet rs) throws java.sql.SQLException {
java.sql.Timestamp ts = rs.getTimestamp("created_at");
java.time.Instant createdAt = ts != null ? ts.toInstant() : null;

View File

@@ -0,0 +1,119 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that the {@code max_users} cap is enforced at
* {@code POST /api/v1/admin/users}. The IT installs a synthetic license that lowers the cap to
* {@code 2} so the rejection lands on a small number of HTTP calls. The structured 403 envelope
* is produced by {@link LicenseExceptionAdvice}; the {@code cap_exceeded} audit row is written
* by {@link LicenseEnforcer} when its 3-arg ctor wires {@code AuditService} (T16).
*/
class UserCapEnforcementIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String adminJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
// Defensive: a sibling IT may have left a license installed (LicenseGate is a singleton
// per Spring context; @SpringBootTest reuses contexts across ITs).
securityHelper.clearTestLicense();
// Strip user_roles (FK to users) before users themselves.
jdbcTemplate.update("DELETE FROM user_roles");
jdbcTemplate.update("DELETE FROM user_groups");
jdbcTemplate.update("DELETE FROM users");
// Lower max_users to 2 so the cap rejection lands on the third create call.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_users", 2));
// Clear stale audit rows so the cap_exceeded assertion is unambiguous.
jdbcTemplate.update("DELETE FROM audit_log WHERE category = 'LICENSE'");
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
jdbcTemplate.update("DELETE FROM user_roles");
jdbcTemplate.update("DELETE FROM user_groups");
jdbcTemplate.update("DELETE FROM users");
}
private ResponseEntity<String> createUser(String username) {
// Password meets the policy (12+ chars, 3-of-4 character classes, doesn't match username).
String body = """
{
"username": "%s",
"displayName": "User %s",
"email": "%s@example.com",
"password": "Sup3rSecret-Pass!"
}
""".formatted(username, username, username);
return restTemplate.exchange(
"/api/v1/admin/users", HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
String.class);
}
@Test
void createBeyondCap_returns403WithStateAndMessage() throws Exception {
// Synthetic license: max_users = 2. Two creates succeed; the third rejects.
assertThat(createUser("alice").getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(createUser("bob").getStatusCode()).isEqualTo(HttpStatus.OK);
ResponseEntity<String> third = createUser("charlie");
assertThat(third.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
JsonNode body = objectMapper.readTree(third.getBody());
assertThat(body.path("error").asText()).isEqualTo("license cap reached");
assertThat(body.path("limit").asText()).isEqualTo("max_users");
assertThat(body.path("cap").asInt()).isEqualTo(2);
// We installed a synthetic license, so the gate is ACTIVE.
assertThat(body.path("state").asText()).isEqualTo("ACTIVE");
assertThat(body.has("message")).isTrue();
assertThat(body.path("message").asText()).isNotBlank();
// The third user was NOT persisted.
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users WHERE user_id = 'charlie'", Integer.class);
assertThat(count).isZero();
// Total users still 2 — the rejection short-circuited before any upsert.
Integer total = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users", Integer.class);
assertThat(total).isEqualTo(2);
// LicenseEnforcer (3-arg ctor from T16) wrote the cap_exceeded audit row.
Integer auditCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM audit_log
WHERE category = 'LICENSE'
AND action = 'cap_exceeded'
AND target = 'max_users'
AND result = 'FAILURE'
""", Integer.class);
assertThat(auditCount).isEqualTo(1);
}
}

View File

@@ -32,4 +32,7 @@ public interface UserRepository {
/** Mark all tokens issued before {@code timestamp} as revoked for the given user. */
void revokeTokensBefore(String userId, Instant timestamp);
/** Total user count, used for {@code max_users} license cap enforcement. */
long count();
}