diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java index 063219b1..b90f4c7a 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java @@ -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.")); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java index 72d8298d..694283bc 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java @@ -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())); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresUserRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresUserRepository.java index 64ee73a7..f00bac83 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresUserRepository.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresUserRepository.java @@ -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; diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/UserCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/UserCapEnforcementIT.java new file mode 100644 index 00000000..12bacec6 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/UserCapEnforcementIT.java @@ -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 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 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); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/security/UserRepository.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/security/UserRepository.java index c8ff86f4..e39c6920 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/security/UserRepository.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/security/UserRepository.java @@ -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(); }