Config.java

package jasper.config;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import jasper.domain.proj.HasTags;
import jasper.repository.spec.QualifiedTag;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.With;

import java.io.Serializable;
import java.util.Base64;
import java.util.List;
import java.util.Map;

import static jasper.domain.proj.HasOrigin.nesting;
import static jasper.domain.proj.HasTags.hasCapturingTag;
import static jasper.domain.proj.Tag.matchesTag;
import static jasper.repository.spec.QualifiedTag.selector;
import static jasper.repository.spec.QualifiedTag.tagOriginList;
import static jasper.repository.spec.QualifiedTag.tagOriginSelector;
import static java.lang.Math.min;
import static java.util.Comparator.comparingInt;
import static java.util.stream.Collectors.toMap;
import static org.apache.commons.collections4.CollectionUtils.isNotEmpty;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;

public interface Config {
	/**
	 * Root Config installed to _config/server template.
	 * Template will be created with these default values if it does not exist.
	 */
	@Getter
	@Setter
	@Builder
	@With
	@AllArgsConstructor
	@NoArgsConstructor
	@JsonInclude(JsonInclude.Include.NON_NULL)
	class ServerConfig implements Serializable {
		@Builder.Default
		private String emailHost = "jasper.local";
		@Builder.Default
		private int maxSources = 1000;
		@Builder.Default
		private List<String> modSeals = List.of("seal", "+seal", "_seal", "_moderated");
		@Builder.Default
		private List<String> editorSeals = List.of("plugin/qc");
		/**
		 * Whitelist origins to be allowed web access.
		 */
		@Builder.Default
		private List<String> webOrigins = List.of("");
		@JsonIgnore
		@Builder.Default
		private List<QualifiedTag> _webOriginsParsed = null;
		@JsonIgnore
		public List<QualifiedTag> webOriginsParsed() {
			if (webOrigins == null) return null;
			if (_webOriginsParsed == null) _webOriginsParsed = tagOriginList(webOrigins);
			return _webOriginsParsed;
		}
		@JsonIgnore
		public boolean web(String origin) {
			if (webOriginsParsed() == null) return false;
			var target = selector(origin);
			return webOriginsParsed().stream().anyMatch(s -> s.captures(target));
		}
		@Builder.Default
		private int maxReplEntityBatch = 500;
		/**
		 * Whitelist origins to be allowed to open SSH tunnels.
		 */
		@Builder.Default
		private List<String> sshOrigins = List.of("");
		@JsonIgnore
		@Builder.Default
		private List<QualifiedTag> _sshOriginsParsed = null;
		@JsonIgnore
		public List<QualifiedTag> sshOriginsParsed() {
			if (sshOrigins == null) return null;
			if (_sshOriginsParsed == null) _sshOriginsParsed = tagOriginList(sshOrigins);
			return _sshOriginsParsed;
		}
		@JsonIgnore
		public boolean ssh(String origin) {
			if (sshOriginsParsed() == null) return false;
			var target = selector(origin);
			return sshOriginsParsed().stream().anyMatch(s -> s.captures(target) && nesting(origin) == nesting(s.origin));
		}
		@Builder.Default
		private int maxPushEntityBatch = 5000;
		@Builder.Default
		private int maxPullEntityBatch = 5000;
		/**
		 * Whitelist selectors to run scripts on. No origin wildcards.
		 */
		@Builder.Default
		private List<String> scriptSelectors = List.of("");
		@JsonIgnore
		@Builder.Default
		private List<QualifiedTag> _scriptSelectorsParsed = null;
		@JsonIgnore
		public List<QualifiedTag> scriptSelectorsParsed() {
			if (scriptSelectors == null) return null;
			if (_scriptSelectorsParsed == null) _scriptSelectorsParsed = tagOriginList(scriptSelectors);
			return _scriptSelectorsParsed;
		}
		@JsonIgnore
		public boolean script(String plugin) {
			if (scriptSelectorsParsed() == null) return false;
			return scriptSelectorsParsed().stream().anyMatch(s -> s.captures(tagOriginSelector(plugin + s.origin)));
		}
		@JsonIgnore
		public boolean script(String plugin, String origin) {
			if (scriptSelectorsParsed() == null) return false;
			var target = tagOriginSelector(plugin + origin);
			return scriptSelectorsParsed().stream().anyMatch(s -> s.captures(target) && nesting(origin) == nesting(s.origin));
		}
		@JsonIgnore
		public boolean script(String plugin, HasTags ref) {
			if (ref == null) return false;
			if (ref.getTags() == null) return false;
			if (scriptSelectorsParsed() == null) return false;
			var origin = isBlank(ref.getOrigin()) ? "@" : ref.getOrigin();
			var filtered = ref.getTags().stream().filter(t -> matchesTag(plugin, t)).toList();
			return scriptSelectorsParsed().stream().anyMatch(s -> hasCapturingTag(filtered, origin, s) && nesting(ref.getOrigin()) == nesting(s.origin));
		}
		@JsonIgnore
		public List<String> scriptOrigins(String plugin) {
			if (scriptSelectorsParsed() == null) return List.of();
			return scriptSelectorsParsed().stream().filter(s -> s.captures(tagOriginSelector(plugin + s.origin))).map(s -> s.origin).toList();
		}
		/**
		 * Whitelist script SHA-256 hashes allowed to run. Allows any scripts if empty.
		 */
		@Builder.Default
		private List<String> scriptWhitelist = null;
		/**
		 * Whitelist domains to be allowed to fetch from.
		 */
		@Builder.Default
		private List<String> hostWhitelist = null;
		/**
		 * Blacklist domains to be allowed to fetch from. Takes precedence over domain whitelist.
		 */
		@Builder.Default
		private List<String> hostBlacklist = List.of("*.local");
		/**
		 * Maximum concurrent script executions. Default 100_000.
		 */
		@Builder.Default
		private int maxConcurrentScripts = 100_000;
		/**
		 * Maximum concurrent replication push/pull operations. Default 3.
		 */
		@Builder.Default
		private int maxConcurrentReplication = 3;
		/**
		 * Maximum HTTP requests per origin every 500 nanoseconds. Default 50.
		 */
		@Builder.Default
		private int maxRequests = 50;
		/**
		 * Global maximum concurrent HTTP requests (across all origins). Default 500.
		 */
		@Builder.Default
		private int maxConcurrentRequests = 500;
		/**
		 * Maximum concurrent fetch operations (scraping). Default 10.
		 */
		@Builder.Default
		private int maxConcurrentFetch = 10;

		public ServerConfig wrap(Props props) {
			var wrapped = this;
			var server = props.getOverride().getServer();
			if (isNotBlank(server.getEmailHost())) wrapped = wrapped.withEmailHost(server.getEmailHost());
			if (server.getMaxSources() != null) wrapped = wrapped.withMaxSources(server.getMaxSources());
			if (isNotEmpty(server.getModSeals())) wrapped = wrapped.withModSeals(server.getModSeals());
			if (isNotEmpty(server.getEditorSeals())) wrapped = wrapped.withEditorSeals(server.getEditorSeals());
			if (isNotEmpty(server.getWebOrigins())) wrapped = wrapped.withWebOrigins(server.getWebOrigins());
			if (server.getMaxReplEntityBatch() != null) wrapped = wrapped.withMaxReplEntityBatch(server.getMaxReplEntityBatch());
			if (isNotEmpty(server.getSshOrigins())) wrapped = wrapped.withSshOrigins(server.getSshOrigins());
			if (server.getMaxPushEntityBatch() != null) wrapped = wrapped.withMaxPushEntityBatch(server.getMaxPushEntityBatch());
			if (server.getMaxPullEntityBatch() != null) wrapped = wrapped.withMaxPullEntityBatch(server.getMaxPullEntityBatch());
			if (isNotEmpty(server.getScriptSelectors())) wrapped = wrapped.withScriptSelectors(server.getScriptSelectors());
			if (isNotEmpty(server.getScriptWhitelist())) wrapped = wrapped.withScriptWhitelist(server.getScriptWhitelist());
			if (isNotEmpty(server.getHostWhitelist())) wrapped = wrapped.withHostWhitelist(server.getHostWhitelist());
			if (isNotEmpty(server.getHostBlacklist())) wrapped = wrapped.withHostBlacklist(server.getHostBlacklist());
			if (server.getMaxRequests() != null) wrapped = wrapped.withMaxRequests(server.getMaxRequests());
			if (server.getMaxConcurrentRequests() != null) wrapped = wrapped.withMaxConcurrentRequests(server.getMaxConcurrentRequests());
			if (server.getMaxConcurrentScripts() != null) wrapped = wrapped.withMaxConcurrentScripts(server.getMaxConcurrentScripts());
			if (server.getMaxConcurrentReplication() != null) wrapped = wrapped.withMaxConcurrentReplication(server.getMaxConcurrentReplication());
			if (server.getMaxConcurrentFetch() != null) wrapped = wrapped.withMaxConcurrentFetch(server.getMaxConcurrentFetch());
			return wrapped;
		}

		public static ServerConfigBuilder builderFor(String origin) {
			return ServerConfig.builder()
				.webOrigins(List.of(origin))
				.sshOrigins(List.of(origin))
				.scriptSelectors(List.of(isBlank(origin) ? "" : origin));
		}
	}

	/**
	 * Tenant Config installed to _config/security template in each tenant.
	 */
	@Getter
	@Setter
	@With
	@AllArgsConstructor
	@NoArgsConstructor
	@JsonInclude(JsonInclude.Include.NON_NULL)
	class SecurityConfig implements Serializable {
		/**
		 * Authentication mode (jwt or jwks).
		 */
		private String mode = "";
		/**
		 * Client ID for OAuth2/JWT authentication.
		 */
		private String clientId = "";
		/**
		 * Base64 encoded secret for JWT validation.
		 */
		private String base64Secret = "";
		/**
		 * Plain text secret for JWT validation (alternative to base64Secret).
		 */
		private String secret = "";
		/**
		 * URI to JWKS endpoint for token validation.
		 */
		private String jwksUri = "";
		/**
		 * OAuth2 token endpoint.
		 */
		private String tokenEndpoint = "";
		/**
		 * SCIM endpoint for user management.
		 */
		private String scimEndpoint = "";
		/**
		 * JWT claim to use as the username.
		 */
		private String usernameClaim = "sub";
		/**
		 * Enable external ID matching for users.
		 */
		private boolean externalId = false;
		/**
		 * Include email domain in username.
		 */
		private boolean emailDomainInUsername = false;
		/**
		 * Root email domain for the server.
		 */
		private String rootEmailDomain = "";
		/**
		 * JWT claim for verified email status.
		 */
		private String verifiedEmailClaim = "verified_email";
		/**
		 * JWT claim for user authorities/roles.
		 */
		private String authoritiesClaim = "auth";
		/**
		 * JWT claim for read access tags.
		 */
		private String readAccessClaim = "readAccess";
		/**
		 * JWT claim for write access tags.
		 */
		private String writeAccessClaim = "writeAccess";
		/**
		 * JWT claim for tag read access.
		 */
		private String tagReadAccessClaim = "tagReadAccess";
		/**
		 * JWT claim for tag write access.
		 */
		private String tagWriteAccessClaim = "tagWriteAccess";
		/**
		 * Minimum role for basic access.
		 */
		private String minRole = "ROLE_ANONYMOUS";
		/**
		 * Minimum role for writing.
		 */
		private String minWriteRole = "ROLE_VIEWER";
		/**
		 * Minimum role for fetching external resources.
		 */
		private String minFetchRole = "ROLE_USER";
		/**
		 * Minimum role for admin config.
		 */
		private String minConfigRole = "ROLE_ADMIN";
		/**
		 * Minimum role for downloading backups.
		 * Backups may contain private data or private SSH keys, so they are extremely sensitive.
		 */
		private String minReadBackupsRole = "ROLE_ADMIN";
		/**
		 * Default role given to every user.
		 */
		private String defaultRole = "ROLE_ANONYMOUS";
		/**
		 * Default user tag given to every logged out user.
		 */
		private String defaultUser = "";
		/**
		 * Default read access tags for all users.
		 */
		private List<String> defaultReadAccess;
		/**
		 * Default write access tags for all users.
		 */
		private List<String> defaultWriteAccess;
		/**
		 * Default tag read access tags for all users.
		 */
		private List<String> defaultTagReadAccess;
		/**
		 * Default tag write access tags for all users.
		 */
		private List<String> defaultTagWriteAccess;
		/**
		 * Maximum HTTP requests per origin every 500 nanoseconds. Default 50.
		 */
		private int maxRequests = 50;
		/**
		 * Maximum concurrent script executions per origin. Default 5.
		 */
		private int maxConcurrentScripts = 5;
		/**
		 * Per-origin script execution limits. Map of origin selector patterns (origin, or tag+origin) to max concurrent value.
		 * No origin wildcards.
		 * Example: {"@myorg": 20, "+plugin/delta@myorg": 15, "_plugin/delta": 5}
		 * If more than one matches, the smallest limit is chosen.
		 */
		private Map<String, Integer> scriptLimits = Map.of();
		@JsonIgnore
		private Map<QualifiedTag, Integer> _scriptLimitsParsed = null;
		@JsonIgnore
		public Map<QualifiedTag, Integer> scriptLimitsParsed() {
			if (scriptLimits == null) return null;
			if (_scriptLimitsParsed == null) _scriptLimitsParsed = scriptLimits.entrySet().stream().collect(toMap(e -> tagOriginSelector(e.getKey()), Map.Entry::getValue));
			return _scriptLimitsParsed;
		}
		@JsonIgnore
		public Integer scriptLimit(String plugin) {
			if (scriptLimitsParsed() == null) return maxConcurrentScripts;
			return min(maxConcurrentScripts, scriptLimitsParsed().entrySet().stream()
				.filter(e -> e.getKey().captures(tagOriginSelector(plugin + e.getKey().origin)))
				.min(comparingInt(Map.Entry::getValue))
				.map(Map.Entry::getValue)
				.orElse(maxConcurrentScripts));
		}
		@JsonIgnore
		public Integer scriptLimit(String plugin, String origin) {
			if (scriptLimitsParsed() == null) return maxConcurrentScripts;
			return min(maxConcurrentScripts, scriptLimitsParsed().entrySet().stream()
				.filter(e -> e.getKey().captures(tagOriginSelector(plugin + origin)))
				.min(comparingInt(Map.Entry::getValue))
				.map(Map.Entry::getValue)
				.orElse(maxConcurrentScripts));
		}

		public byte[] getSecretBytes() {
			if (isNotBlank(secret)) return secret.getBytes();
			return Base64.getDecoder().decode(base64Secret);
		}

		public SecurityConfig wrap(Props props) {
			var wrapped = this;
			var security = props.getOverride().getSecurity();
			if (isNotBlank(security.getMode())) wrapped = wrapped.withMode(security.getMode());
			if (isNotBlank(security.getClientId())) wrapped = wrapped.withClientId(security.getClientId());
			if (isNotBlank(security.getSecret()) || isNotBlank(security.getBase64Secret())) {
				wrapped = wrapped.withBase64Secret(security.getBase64Secret());
				wrapped = wrapped.withSecret(security.getSecret());
			}
			if (isNotBlank(security.getJwksUri())) wrapped = wrapped.withJwksUri(security.getJwksUri());
			if (isNotBlank(security.getUsernameClaim())) wrapped = wrapped.withUsernameClaim(security.getUsernameClaim());
			if (!"unset".equals(security.getVerifiedEmailClaim())) wrapped = wrapped.withVerifiedEmailClaim(security.getVerifiedEmailClaim());
			if (isNotBlank(security.getDefaultUser())) wrapped = wrapped.withDefaultUser(security.getDefaultUser());
			if (isNotBlank(security.getTokenEndpoint())) wrapped = wrapped.withTokenEndpoint(security.getTokenEndpoint());
			if (isNotBlank(security.getScimEndpoint())) wrapped = wrapped.withScimEndpoint(security.getScimEndpoint());
			if (security.getMaxRequests() != null) wrapped = wrapped.withMaxRequests(security.getMaxRequests());
			if (security.getMaxConcurrentScripts() != null) wrapped = wrapped.withMaxConcurrentScripts(security.getMaxConcurrentScripts());
			return wrapped;
		}
	}
}