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;
|
package com.cameleer.server.app.controller;
|
||||||
|
|
||||||
import com.cameleer.server.app.dto.SetPasswordRequest;
|
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.AuditCategory;
|
||||||
import com.cameleer.server.core.admin.AuditResult;
|
import com.cameleer.server.core.admin.AuditResult;
|
||||||
import com.cameleer.server.core.admin.AuditService;
|
import com.cameleer.server.core.admin.AuditService;
|
||||||
@@ -52,13 +53,16 @@ public class UserAdminController {
|
|||||||
private final RbacService rbacService;
|
private final RbacService rbacService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
private final LicenseEnforcer licenseEnforcer;
|
||||||
private final boolean oidcEnabled;
|
private final boolean oidcEnabled;
|
||||||
|
|
||||||
public UserAdminController(RbacService rbacService, UserRepository userRepository,
|
public UserAdminController(RbacService rbacService, UserRepository userRepository,
|
||||||
AuditService auditService, SecurityProperties securityProperties) {
|
AuditService auditService, SecurityProperties securityProperties,
|
||||||
|
LicenseEnforcer licenseEnforcer) {
|
||||||
this.rbacService = rbacService;
|
this.rbacService = rbacService;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
|
this.licenseEnforcer = licenseEnforcer;
|
||||||
String issuer = securityProperties.getOidc().getIssuerUri();
|
String issuer = securityProperties.getOidc().getIssuerUri();
|
||||||
this.oidcEnabled = issuer != null && !issuer.isBlank();
|
this.oidcEnabled = issuer != null && !issuer.isBlank();
|
||||||
}
|
}
|
||||||
@@ -89,6 +93,9 @@ public class UserAdminController {
|
|||||||
@ApiResponse(responseCode = "400", description = "Disabled in OIDC mode")
|
@ApiResponse(responseCode = "400", description = "Disabled in OIDC mode")
|
||||||
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request,
|
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request,
|
||||||
HttpServletRequest httpRequest) {
|
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) {
|
if (oidcEnabled) {
|
||||||
return ResponseEntity.badRequest()
|
return ResponseEntity.badRequest()
|
||||||
.body(Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO."));
|
.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.AuthTokenResponse;
|
||||||
import com.cameleer.server.app.dto.ErrorResponse;
|
import com.cameleer.server.app.dto.ErrorResponse;
|
||||||
import com.cameleer.server.app.dto.OidcPublicConfigResponse;
|
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.AuditCategory;
|
||||||
import com.cameleer.server.core.admin.AuditResult;
|
import com.cameleer.server.core.admin.AuditResult;
|
||||||
import com.cameleer.server.core.admin.AuditService;
|
import com.cameleer.server.core.admin.AuditService;
|
||||||
@@ -63,6 +64,7 @@ public class OidcAuthController {
|
|||||||
private final ClaimMappingService claimMappingService;
|
private final ClaimMappingService claimMappingService;
|
||||||
private final ClaimMappingRepository claimMappingRepository;
|
private final ClaimMappingRepository claimMappingRepository;
|
||||||
private final GroupRepository groupRepository;
|
private final GroupRepository groupRepository;
|
||||||
|
private final LicenseEnforcer licenseEnforcer;
|
||||||
|
|
||||||
public OidcAuthController(OidcTokenExchanger tokenExchanger,
|
public OidcAuthController(OidcTokenExchanger tokenExchanger,
|
||||||
OidcConfigRepository configRepository,
|
OidcConfigRepository configRepository,
|
||||||
@@ -72,7 +74,8 @@ public class OidcAuthController {
|
|||||||
RbacService rbacService,
|
RbacService rbacService,
|
||||||
ClaimMappingService claimMappingService,
|
ClaimMappingService claimMappingService,
|
||||||
ClaimMappingRepository claimMappingRepository,
|
ClaimMappingRepository claimMappingRepository,
|
||||||
GroupRepository groupRepository) {
|
GroupRepository groupRepository,
|
||||||
|
LicenseEnforcer licenseEnforcer) {
|
||||||
this.tokenExchanger = tokenExchanger;
|
this.tokenExchanger = tokenExchanger;
|
||||||
this.configRepository = configRepository;
|
this.configRepository = configRepository;
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
@@ -82,6 +85,7 @@ public class OidcAuthController {
|
|||||||
this.claimMappingService = claimMappingService;
|
this.claimMappingService = claimMappingService;
|
||||||
this.claimMappingRepository = claimMappingRepository;
|
this.claimMappingRepository = claimMappingRepository;
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
|
this.licenseEnforcer = licenseEnforcer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,6 +158,13 @@ public class OidcAuthController {
|
|||||||
"Account not provisioned. Contact your administrator.");
|
"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(
|
userRepository.upsert(new UserInfo(
|
||||||
userId, provider, oidcUser.email(), oidcUser.name(), Instant.now()));
|
userId, provider, oidcUser.email(), oidcUser.name(), Instant.now()));
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,12 @@ public class PostgresUserRepository implements UserRepository {
|
|||||||
java.sql.Timestamp.from(timestamp), userId);
|
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 {
|
private UserInfo mapUser(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||||
java.sql.Timestamp ts = rs.getTimestamp("created_at");
|
java.sql.Timestamp ts = rs.getTimestamp("created_at");
|
||||||
java.time.Instant createdAt = ts != null ? ts.toInstant() : null;
|
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. */
|
/** Mark all tokens issued before {@code timestamp} as revoked for the given user. */
|
||||||
void revokeTokensBefore(String userId, Instant timestamp);
|
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