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:
@@ -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."));
|
||||
|
||||
@@ -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()));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user